4 Commits
53 changed files with 2243 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"setup-worktree-windows": [
"go mod download",
"if (Test-Path web/package.json) { pnpm --dir web install }"
],
"setup-worktree-unix": [
"go mod download",
"if [ -f web/package.json ]; then pnpm --dir web install; fi"
],
"setup-worktree": [
"go mod download",
"pnpm --dir web install"
]
}
+1
View File
@@ -3,6 +3,7 @@
/dist/
/web/dist/
node_modules/
.pnpm-store/
*.exe
*.test
*.out
+34
View File
@@ -0,0 +1,34 @@
version: "2"
run:
timeout: 5m
formatters:
enable:
- gofmt
- goimports
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- misspell
- unconvert
settings:
govet:
enable:
- nilness
- shadow
exclusions:
paths:
- web/node_modules
- third_party
- builtin
- examples
issues:
max-issues-per-linter: 0
max-same-issues: 0
+73
View File
@@ -0,0 +1,73 @@
version: "3"
vars:
BIN_DIR: bin
BIN_NAME: nixmsg
EMBED_TAG: embeddist
EXE: '{{if eq OS "windows"}}.exe{{end}}'
includes:
'*':
taskfile: taskfiles/*.yml
optional: true
tasks:
default:
desc: 显示可用任务
cmds:
- task --list
web:install:
desc: 安装前端依赖
dir: web
cmds:
- pnpm install
web:dev:
desc: 启动前端开发服务器
dir: web
deps: [web:install]
cmds:
- pnpm dev
web:build:
desc: 构建前端静态资源到 web/dist
dir: web
deps: [web:install]
cmds:
- pnpm build
build:
desc: 构建嵌入前端的单个可执行文件
deps: [web:build]
cmds:
- go build -tags {{.EMBED_TAG}} -o {{.BIN_DIR}}/{{.BIN_NAME}}{{.EXE}} ./cmd/nixmsg
env:
CGO_ENABLED: "0"
test:
desc: 运行单元测试
cmds:
- go test ./...
lint:
desc: 运行 golangci-lint
cmds:
- golangci-lint run ./...
check:
desc: 构建、检查与单元测试
cmds:
- task: build
- task: lint
- task: test
itest:
desc: 运行集成测试(T0.5 后可用)
cmds:
- go test ./test/... -count=1
docker:
desc: 构建开发用 Docker 镜像
cmds:
- docker build -t nixmsg:dev -f deploy/Dockerfile .
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: nixmsg <command>")
os.Exit(2)
}
switch os.Args[1] {
case "version":
cmdVersion(os.Args[2:])
case "serve":
if err := cmdServe(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(2)
}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/store"
"git.asio.asia/nixevol/NixMsg/web"
)
func cmdServe(_ []string) error {
cfgPath := config.PathFromEnv()
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return runServe(ctx, cfg)
}
func runServe(ctx context.Context, cfg config.Config) error {
wire()
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return fmt.Errorf("mkdir data_dir: %w", err)
}
db, err := store.OpenWriter(cfg.DataDir, cfg.SQLiteSynchronous)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
if migErr := store.Migrate(db); migErr != nil {
return migErr
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// 确保前端资源被链接进二进制;完整静态托管由后续任务完善。
_ = web.Dist()
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
return fmt.Errorf("listen %s: %w", cfg.Listen, err)
}
if err := writeListenAddr(cfg.DataDir, ln.Addr().String()); err != nil {
_ = ln.Close()
return err
}
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.Serve(ln)
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
serveErr := <-errCh
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
}
return nil
case serveErr := <-errCh:
if errors.Is(serveErr, http.ErrServerClosed) {
return nil
}
return serveErr
}
}
func writeListenAddr(dataDir, addr string) error {
path := filepath.Join(dataDir, "listen.addr")
return os.WriteFile(path, []byte(addr+"\n"), 0o644)
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/config"
)
func TestServeHealthzAndListenAddr(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfgPath := filepath.Join(dataDir, "config.yaml")
cfgYAML := []byte("listen: \"127.0.0.1:0\"\ndata_dir: \"" + filepath.ToSlash(dataDir) + "\"\n")
if err := os.WriteFile(cfgPath, cfgYAML, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- runServe(ctx, cfg)
}()
var addr string
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
b, readErr := os.ReadFile(filepath.Join(dataDir, "listen.addr"))
if readErr == nil {
addr = strings.TrimSpace(string(b))
if addr != "" {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if addr == "" {
t.Fatal("listen.addr not written")
}
resp, err := http.Get("http://" + addr + "/healthz")
if err != nil {
t.Fatalf("healthz: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if string(body) != "ok" {
t.Fatalf("unexpected body: %q", body)
}
if _, err := os.Stat(filepath.Join(dataDir, "nixmsg.db")); err != nil {
t.Fatalf("db missing: %v", err)
}
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("serve exit: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("serve did not stop")
}
}
+13
View File
@@ -0,0 +1,13 @@
package main
import (
"fmt"
"runtime"
)
// Version 由构建注入;未注入时显示开发占位。
var Version = "dev"
func cmdVersion(_ []string) {
fmt.Printf("nixmsg %s %s/%s\n", Version, runtime.GOOS, runtime.GOARCH)
}
+4
View File
@@ -0,0 +1,4 @@
package main
// wire 组装各业务模块。T0.1 仅占位,T0.4 起由各线注册。
func wire() {}
+24
View File
@@ -0,0 +1,24 @@
# 多阶段骨架:完整多架构发布见 Q4。
# 语法阶段:Node 构建前端 → Go 编译(嵌入前端)→ distroless 运行。
FROM node:24-bookworm AS web
WORKDIR /src/web
COPY web/package.json web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm build
FROM golang:1.27-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/web/dist ./web/dist
ENV CGO_ENABLED=0
RUN go build -tags embeddist -o /out/nixmsg ./cmd/nixmsg
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/nixmsg /nixmsg
USER nonroot:nonroot
ENTRYPOINT ["/nixmsg"]
CMD ["serve"]
+31
View File
@@ -0,0 +1,31 @@
listen: ":7443" # 端接入端口
admin_listen: "" # 后台单独监听,如 "127.0.0.1:7444";空表示后台也在 listen 上
tls:
cert_file: ""
key_file: ""
allow_plaintext: false
trusted_proxies: [] # 反向代理的地址段,如 ["127.0.0.1/32"]
data_dir: "./data"
limits:
max_body_bytes: 262144 # 只能调小
max_meta_bytes: 4096
max_frame_bytes: 786432
max_ttl_seconds: 2592000
max_schedule_seconds: 31536000
max_group_members: 1000
grace_seconds: 60
ack_timeout_seconds: 300
delivery_window: 32
receipt_window: 64
requests_per_second: 50 # 除 ack、receipt_ack 外,突发容量为 2 倍
max_pending_per_sender: 10000 # 等待发送或投递中的消息数,群消息算一条;0 表示不限
max_pending_per_receiver: 10000 # 排队未收下的投递数;0 表示不限
session_idle_days: 30 # 会话令牌多少天没用就失效;0 表示不失效
record_retention_days: 7 # 0 表示完成后不留记录
idempotency_hours: 24
receipt_retention_days: 7
sqlite_synchronous: FULL # 或 NORMAL
metrics:
token: "" # 后台和端共用端口时,访问 /metrics 要带它;空表示共用端口时不提供
log:
level: info
+36 -1
View File
@@ -6,7 +6,42 @@
## 总控 L
暂无。
### T0.1 2026-09-30
1. **前端嵌入方式**
- 原条款:DEVELOPMENT 第 3 节 `web/embed.go` 用 `//go:embed all:dist`;TASKS T0.1;`.gitignore` 忽略 `web/dist`。
- 实际做法:默认构建用 `-tags` 以外的 `embed_stub.go` 嵌入 `web/stub/`;`task build` 加 `-tags embeddist` 嵌入真实 `web/dist`。
- 原因:`web/dist` 不进仓库,且要求没有前端产物时也能 `go build`。
- 备选方案:提交最小 `dist`;或构建前脚本生成占位目录。
- 影响:裸 `go build` 不含真实前端;正式产物必须走 `task build`。
2. **listen.addr 写入时机**
- 原条款:DEVELOPMENT 4.1「端口写 0 时」写 `<data_dir>/listen.addr`。
- 实际做法:只要 `serve` 启动成功就写入实际监听地址。
- 原因:测试启动器统一读取该文件更简单,固定端口场景也无害。
- 备选方案:仅当配置端口为 0 时写入。
- 影响:多一个小文件;行为超集,兼容文档要求。
3. **迁移占位**
- 原条款:T0.3 才写完整表与 `VACUUM INTO` 备份;T0.1 允许空执行器加空 0001。
- 实际做法:`internal/store.Migrate` 建 `schema_migrations` 并应用 `0001_init.sql`(内容为 `SELECT 1;`);不做迁移前备份。
- 原因:保证 serve 可跑通迁移路径,表结构留给 T0.3。
- 备选方案:完全空文件 + 只记版本。
- 影响:T0.3 需替换 0001 正文并补备份逻辑;已应用的占位版本号仍为 1。
4. **Taskfile 引入 taskfiles**
- 原条款:TASKS T0.1 / 4.2 引入 `taskfiles/*.yml`。
- 实际做法:`includes: '*': taskfile: taskfiles/*.yml, optional: true`,并放 `_init.yml` 占位。
- 原因:空目录 glob 可能失败;各线稍后加自己的 yml。
- 备选方案:主文件逐条 optional include 各线文件名。
- 影响:无。
5. **deploy/Dockerfile**
- 原条款:完整多架构镜像属 Q4;T0.1 需要 `task docker` 目标。
- 实际做法:提供单架构多阶段 Dockerfile 骨架,供 `task docker` 使用;T0.1 验证另用官方 `golang` 镜像跑 `task check`。
- 原因:让 docker 目标可执行,又不抢 Q4 范围。
- 备选方案:docker 目标仅 echo 提示。
- 影响:镜像发布流程仍由 Q4 定稿。
## 平台 P
+20
View File
@@ -0,0 +1,20 @@
module git.asio.asia/nixevol/NixMsg
go 1.27
require (
go.yaml.in/yaml/v3 v3.0.5
modernc.org/sqlite v1.60.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
View File
View File
View File
View File
View File
View File
View File
+122
View File
@@ -0,0 +1,122 @@
package config
import (
"fmt"
"os"
"go.yaml.in/yaml/v3"
)
// Config 对应 DEVELOPMENT 第 11.1 节的配置文件。
type Config struct {
Listen string `yaml:"listen"`
AdminListen string `yaml:"admin_listen"`
TLS TLSConfig `yaml:"tls"`
TrustedProxies []string `yaml:"trusted_proxies"`
DataDir string `yaml:"data_dir"`
Limits LimitsConfig `yaml:"limits"`
SessionIdleDays int `yaml:"session_idle_days"`
RecordRetentionDays int `yaml:"record_retention_days"`
IdempotencyHours int `yaml:"idempotency_hours"`
ReceiptRetentionDays int `yaml:"receipt_retention_days"`
SQLiteSynchronous string `yaml:"sqlite_synchronous"`
Metrics MetricsConfig `yaml:"metrics"`
Log LogConfig `yaml:"log"`
}
type TLSConfig struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
AllowPlaintext bool `yaml:"allow_plaintext"`
}
type LimitsConfig struct {
MaxBodyBytes int `yaml:"max_body_bytes"`
MaxMetaBytes int `yaml:"max_meta_bytes"`
MaxFrameBytes int `yaml:"max_frame_bytes"`
MaxTTLSeconds int `yaml:"max_ttl_seconds"`
MaxScheduleSeconds int `yaml:"max_schedule_seconds"`
MaxGroupMembers int `yaml:"max_group_members"`
GraceSeconds int `yaml:"grace_seconds"`
AckTimeoutSeconds int `yaml:"ack_timeout_seconds"`
DeliveryWindow int `yaml:"delivery_window"`
ReceiptWindow int `yaml:"receipt_window"`
RequestsPerSecond int `yaml:"requests_per_second"`
MaxPendingPerSender int `yaml:"max_pending_per_sender"`
MaxPendingPerReceiver int `yaml:"max_pending_per_receiver"`
}
type MetricsConfig struct {
Token string `yaml:"token"`
}
type LogConfig struct {
Level string `yaml:"level"`
}
// Default 返回与 DEVELOPMENT 11.1 示例一致的默认值。
func Default() Config {
return Config{
Listen: ":7443",
AdminListen: "",
TLS: TLSConfig{
AllowPlaintext: false,
},
TrustedProxies: nil,
DataDir: "./data",
Limits: LimitsConfig{
MaxBodyBytes: 262144,
MaxMetaBytes: 4096,
MaxFrameBytes: 786432,
MaxTTLSeconds: 2592000,
MaxScheduleSeconds: 31536000,
MaxGroupMembers: 1000,
GraceSeconds: 60,
AckTimeoutSeconds: 300,
DeliveryWindow: 32,
ReceiptWindow: 64,
RequestsPerSecond: 50,
MaxPendingPerSender: 10000,
MaxPendingPerReceiver: 10000,
},
SessionIdleDays: 30,
RecordRetentionDays: 7,
IdempotencyHours: 24,
ReceiptRetentionDays: 7,
SQLiteSynchronous: "FULL",
Metrics: MetricsConfig{},
Log: LogConfig{
Level: "info",
},
}
}
// Load 从路径加载 YAML 配置;空字段保留默认值。
func Load(path string) (Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
if cfg.Listen == "" {
cfg.Listen = Default().Listen
}
if cfg.DataDir == "" {
cfg.DataDir = Default().DataDir
}
if cfg.SQLiteSynchronous == "" {
cfg.SQLiteSynchronous = Default().SQLiteSynchronous
}
return cfg, nil
}
// PathFromEnv 返回 NIXMSG_CONFIG 或默认 ./config.yaml。
func PathFromEnv() string {
if p := os.Getenv("NIXMSG_CONFIG"); p != "" {
return p
}
return "./config.yaml"
}
View File
View File
View File
+49
View File
@@ -0,0 +1,49 @@
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
// OpenWriter 按 DEVELOPMENT 7.7 打开写连接(带 _txlock=immediate)。
func OpenWriter(dataDir, synchronous string) (*sql.DB, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("mkdir data_dir: %w", err)
}
dsn, err := writeDSN(dataDir, synchronous)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return db, nil
}
func writeDSN(dataDir, synchronous string) (string, error) {
sync := strings.ToUpper(strings.TrimSpace(synchronous))
if sync == "" {
sync = "FULL"
}
if sync != "FULL" && sync != "NORMAL" {
return "", fmt.Errorf("invalid sqlite_synchronous: %s", synchronous)
}
dbPath := filepath.ToSlash(filepath.Join(dataDir, "nixmsg.db"))
return fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(%s)&_pragma=foreign_keys(ON)&_pragma=secure_delete(ON)&_txlock=immediate",
dbPath,
sync,
), nil
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"path/filepath"
"testing"
)
func TestOpenAndMigrate(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db, err := OpenWriter(dir, "FULL")
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
if migErr := Migrate(db); migErr != nil {
t.Fatal(migErr)
}
if migErr := Migrate(db); migErr != nil {
t.Fatal(migErr)
}
var n int
if scanErr := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&n); scanErr != nil {
t.Fatal(scanErr)
}
if n != 1 {
t.Fatalf("want 1 migration row, got %d", n)
}
nested, openErr := OpenWriter(filepath.Join(dir, "nested"), "NORMAL")
if openErr != nil {
t.Fatal(openErr)
}
_ = nested.Close()
}
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migrate 应用尚未执行的迁移。T0.1 仅为空执行器加 0001 占位;完整备份与表结构见 T0.3。
func Migrate(db *sql.DB) error {
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var names []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
version, err := parseMigrationVersion(name)
if err != nil {
return err
}
var exists int
err = db.QueryRow(`SELECT 1 FROM schema_migrations WHERE version = ?`, version).Scan(&exists)
if err == nil {
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("check migration %d: %w", version, err)
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := strings.TrimSpace(string(body))
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin migration %d: %w", version, err)
}
if sqlText != "" {
if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %d: %w", version, err)
}
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
version,
time.Now().UnixMilli(),
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %d: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", version, err)
}
}
return nil
}
func parseMigrationVersion(name string) (int, error) {
base := strings.TrimSuffix(name, ".sql")
parts := strings.SplitN(base, "_", 2)
if len(parts) < 1 {
return 0, fmt.Errorf("invalid migration name: %s", name)
}
v, err := strconv.Atoi(parts[0])
if err != nil {
return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
}
return v, nil
}
+1
View File
@@ -0,0 +1 @@
SELECT 1;
View File
View File
View File
View File
+6
View File
@@ -0,0 +1,6 @@
version: "3"
# 各开发线把自己的任务写到本目录 <线名>.yml(如 p.yml、n.yml)。
# 主 Taskfile 通过 includes 的 glob 引入;本文件仅占位,避免空目录无法匹配。
tasks: {}
View File
+1
View File
@@ -0,0 +1 @@
onlyBuiltDependencies[]=esbuild
+10
View File
@@ -0,0 +1,10 @@
package web
import "io/fs"
// Dist 返回嵌入的前端静态资源。
// 默认(无 embeddist 标签)为 stub 占位,保证裸 go build 可通过;
// task build 使用 -tags embeddist 嵌入 pnpm build 产物。
func Dist() fs.FS {
return distFS
}
+8
View File
@@ -0,0 +1,8 @@
//go:build embeddist
package web
import "embed"
//go:embed all:dist
var distFS embed.FS
+8
View File
@@ -0,0 +1,8 @@
//go:build !embeddist
package web
import "embed"
//go:embed all:stub
var distFS embed.FS
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NixMsg</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "nixmsg-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --noEmit"
},
"dependencies": {
"naive-ui": "^2.43.1",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^26.6.3",
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "~5.9.2",
"vite": "^7.1.7",
"vue-tsc": "^3.0.8"
},
"packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
+1207
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import { RouterView } from "vue-router";
</script>
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" size="small">
<n-message-provider>
<n-dialog-provider>
<RouterView />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import naive from "naive-ui";
import App from "./App.vue";
import { router } from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(naive);
app.mount("#app");
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
],
});
+7
View File
@@ -0,0 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export const useAppStore = defineStore("app", () => {
const title = ref("NixMsg");
return { title };
});
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { NCard, NSpace, NText } from "naive-ui";
import { storeToRefs } from "pinia";
import { useAppStore } from "@/stores/app";
const { title } = storeToRefs(useAppStore());
</script>
<template>
<n-space vertical style="padding: 24px">
<n-card :title="title">
<n-text>管理后台骨架(T0.1)。后续页面由 W 线实现。</n-text>
</n-card>
</n-space>
</template>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NixMsg</title>
</head>
<body>
<p>前端尚未构建。请先执行 <code>task web:build</code> 再 <code>task build</code>。</p>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": path.resolve(rootDir, "src"),
},
},
server: {
proxy: {
"/api": "http://127.0.0.1:7443",
"/healthz": "http://127.0.0.1:7443",
},
},
});