commit 967dcecb7e8f4efa6b603c0973b2c19ddfc0552c Author: Rogee Date: Tue Sep 22 22:51:19 2026 +0800 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5379c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.codex +backend/data +**/node_modules +**/.DS_Store + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8abc443 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy this file to .env and fill the values locally. +# Do not commit the real .env file. + +GUAHAO_ADMIN_AUTH_MODE=none +GUAHAO_ADMIN_SESSION_SECRET= +GUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL=https://gh.yqbmb.com/admin/price-inquiries +GUAHAO_OIDC_ISSUER=https://auth.yqbmb.com/oidc +GUAHAO_OIDC_CLIENT_ID= +GUAHAO_OIDC_CLIENT_SECRET= +GUAHAO_OIDC_REDIRECT_URI=http://127.0.0.1:9800/admin/oidc/callback +GUAHAO_OIDC_SCOPES=openid profile email phone +GUAHAO_WECHAT_APPID= +GUAHAO_WECHAT_SECRET= +GUAHAO_UPLOAD_DIR=data/uploads +GUAHAO_CRM_CUSTOMER_URL=https://auto.meibamei.com/push/yhma-customer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffde631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.codex/ +.codegraph/ + +# Environment files +.env +.env.* +!.env.example + +# Build caches and generated output +.cache/ +tmp/ +dist/ +build/ +coverage/ +*.tsbuildinfo +**/unpackage/ + +# Backend runtime artifacts +backend/data/ +backend/tmp/ +backend/smilefirst +backend/*.test +backend/bin/ +backend/coverage.out +*.db +*.db-shm +*.db-wal + +# Miniapp dependencies and generated WeChat npm output +node_modules/ +miniapp/miniprogram_npm/ +miniapp/project.private.config.json + +# Local/editor noise +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bed289c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# 项目约定 + +- 修改 `backend/queries/*.sql` 后必须执行 `sqlc generate`,不得手改 `backend/internal/db/*.sql.go`。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c09f76 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +IMAGE_NAME ?= rogeecn/weixin-guahao +IMAGE_TAG ?= latest +IMAGE ?= $(IMAGE_NAME):$(IMAGE_TAG) + +DEPLOY_HOST ?= server.gaozong.ali +DEPLOY_DIR ?= dist +DEPLOY_ARCHIVE ?= $(DEPLOY_DIR)/$(subst /,_,$(IMAGE_NAME))-$(IMAGE_TAG).tar.gz +DEPLOY_REMOTE_ARCHIVE ?= /tmp/$(notdir $(DEPLOY_ARCHIVE)) + +DOCKERFILE ?= backend/Dockerfile +BUILD_CONTEXT ?= backend +PLATFORM ?= linux/amd64 + +DOCKER_REGISTRY ?= docker.m.daocloud.io +ALPINE_MIRROR ?= mirrors.aliyun.com +GOPROXY ?= https://goproxy.cn,direct +GOSUMDB ?= sum.golang.google.cn + +.PHONY: docker-build docker-push docker-release deploy compose-up compose-down + +docker-build: + docker build \ + --platform $(PLATFORM) \ + --build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ + --build-arg ALPINE_MIRROR=$(ALPINE_MIRROR) \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg GOSUMDB=$(GOSUMDB) \ + -t $(IMAGE) \ + -f $(DOCKERFILE) \ + $(BUILD_CONTEXT) + +docker-push: + docker push $(IMAGE) + +docker-release: docker-build docker-push + +deploy: docker-build + mkdir -p $(DEPLOY_DIR) + docker save $(IMAGE) | gzip > $(DEPLOY_ARCHIVE) + scp $(DEPLOY_ARCHIVE) $(DEPLOY_HOST):$(DEPLOY_REMOTE_ARCHIVE) + ssh $(DEPLOY_HOST) 'docker load -i $(DEPLOY_REMOTE_ARCHIVE) && rm -f $(DEPLOY_REMOTE_ARCHIVE)' + +compose-up: + docker compose up -d + +compose-down: + docker compose down diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d4d11f --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Smile First 口腔护理预约小程序 + +Smile First 是一个微信小程序预约 MVP,覆盖首页、选择项目、选择日期与时间、确认预约、预约成功、我的预约,以及 Go 后端 API。 + +## 技术栈 + +- 小程序:原生 WXML / WXSS / JS +- 后端:Go、Fiber、sqlc、Cobra、logrus +- 数据库:SQLite +- 部署:Docker / docker compose + +## 本地运行后端 + +```bash +cp .env.example .env +# 本地开发可使用 GUAHAO_ADMIN_AUTH_MODE=none 直接进入后台 +# 生产环境建议使用 GUAHAO_ADMIN_AUTH_MODE=oidc 并填写 OIDC 配置 +# 体验版获取手机号还需要填写 GUAHAO_WECHAT_APPID / GUAHAO_WECHAT_SECRET + +cd backend +go run ./cmd/smilefirst serve +``` + +默认监听 `:9800`,启动时会自动应用 schema 并插入默认项目、排期与时段。 + +管理后台入口: + +- 登录页:`http://127.0.0.1:9800/admin/login` +- 默认页:`/admin/price-inquiries` +- 价格咨询派单:`/admin/price-inquiries`,展示授权手机号提交的地区、咨询项目、创建时间和更新时间 + +后台登录模式、OIDC 配置和微信小程序凭证从本地 `.env` 或运行环境变量读取,真实 `.env` 不提交到代码库: + +```env +GUAHAO_ADMIN_AUTH_MODE=none +GUAHAO_ADMIN_SESSION_SECRET= +GUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL=https://gh.yqbmb.com/admin/price-inquiries +GUAHAO_OIDC_ISSUER=https://auth.yqbmb.com/oidc +GUAHAO_OIDC_CLIENT_ID= +GUAHAO_OIDC_CLIENT_SECRET= +GUAHAO_OIDC_REDIRECT_URI=http://127.0.0.1:9800/admin/oidc/callback +GUAHAO_OIDC_SCOPES=openid profile email phone +GUAHAO_WECHAT_APPID= +GUAHAO_WECHAT_SECRET= +GUAHAO_UPLOAD_DIR=data/uploads +``` + +`GUAHAO_ADMIN_AUTH_MODE=none` 会跳过后台登录,仅建议本地开发使用;`oidc` 会通过 Logto/OIDC 授权登录,回调地址为 `/admin/oidc/callback`,登录后后台右上角展示授权用户姓名和修改密码入口。修改密码入口会跳转到 Logto Account Center 的 `/account/password`,完成后返回 `GUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL`。 + +后台上传的首页图片和项目图片会保存到 `GUAHAO_UPLOAD_DIR`,并通过 `/uploads/...` 访问。 + +## Docker 部署 + +```bash +docker compose up --build +``` + +接口健康检查: + +```bash +curl http://127.0.0.1:9800/healthz +``` + +## 小程序开发 + +用微信开发者工具打开 `uni-guahao/` 目录。小程序请求地址由 `uni-guahao/utils/api.uts` 自动选择,不需要发布前手动改 URL: + +- 微信开发版 `develop`:`http://localhost:9800` +- 微信体验版 `trial` / 正式版 `release`:`https://gh.yqbmb.com` + +线上域名需要在微信公众平台配置为 request 合法域名;本地开发时 `manifest.json` 已关闭开发域名校验。 + +首页不配置底部 `tabBar`。用户可直接选择项目和预约时段;在确认预约时授权手机号,授权成功后自动提交预约。确认页可通过半屏底部弹层查看《预约服务条款》。 + +## 关键目录 + +- `uni-guahao/`:uni-app x 微信小程序前端 +- `backend/`:Go API 服务 +- `backend/migrations/schema.sql`:SQLite schema +- `backend/queries/`:sqlc 查询 +- `uni-guahao/static/`:小程序静态资源 +- `docs/design-elements.md`:设计元素、裁切坐标和 token diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 0000000..ef0036c --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,59 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/smilefirst/main.go" + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + mode = "" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5773a0f --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example +data +tmp +*.db +*.log +coverage.out diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..40e6dba --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +ARG DOCKER_REGISTRY=docker.m.daocloud.io +ARG ALPINE_MIRROR=mirrors.aliyun.com +ARG GOPROXY=https://goproxy.cn,direct +ARG GOSUMDB=sum.golang.google.cn + +FROM ${DOCKER_REGISTRY}/library/golang:1.26-alpine AS build + +ARG ALPINE_MIRROR +ARG GOPROXY +ARG GOSUMDB +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +ENV GOPROXY=${GOPROXY} +ENV GOSUMDB=${GOSUMDB} + +WORKDIR /src +RUN sed -i "s/dl-cdn.alpinelinux.org/${ALPINE_MIRROR}/g" /etc/apk/repositories \ + && apk add --no-cache ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/smilefirst ./cmd/smilefirst + +FROM ${DOCKER_REGISTRY}/library/alpine:3.22 + +ARG ALPINE_MIRROR + +WORKDIR /app +RUN sed -i "s/dl-cdn.alpinelinux.org/${ALPINE_MIRROR}/g" /etc/apk/repositories \ + && apk add --no-cache ca-certificates tzdata + +COPY --from=build /out/smilefirst /app/smilefirst +COPY migrations /app/migrations + +ENV GUAHAO_ADDR=:9800 +ENV GUAHAO_DB_PATH=/data/smilefirst.db +ENV GUAHAO_UPLOAD_DIR=/data/uploads +ENV GUAHAO_SCHEMA_PATH=/app/migrations/schema.sql +ENV GUAHAO_LOG_LEVEL=info +ENV TZ=Asia/Shanghai + +VOLUME ["/data"] +EXPOSE 9800 + +CMD ["/app/smilefirst", "serve"] diff --git a/backend/cmd/smilefirst/main.go b/backend/cmd/smilefirst/main.go new file mode 100644 index 0000000..aaf5edc --- /dev/null +++ b/backend/cmd/smilefirst/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/httpapi" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + _ "modernc.org/sqlite" +) + +func main() { + if err := rootCommand().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func rootCommand() *cobra.Command { + cfg := config.Load() + return newRootCommand(&cfg, commandHandlers{}) +} + +type commandRunner func(cmd *cobra.Command, args []string) error + +type commandHandlers struct { + serve commandRunner + migrate commandRunner + seed commandRunner +} + +func newRootCommand(cfg *config.Config, handlers commandHandlers) *cobra.Command { + if handlers.serve == nil { + handlers.serve = func(cmd *cobra.Command, args []string) error { + if err := cfg.ValidateAdminAuth(); err != nil { + return err + } + log := newLogger(cfg.LogLevel) + database, svc, err := openService(cmd.Context(), *cfg, log) + if err != nil { + return err + } + defer database.Close() + app := httpapi.New(*cfg, svc, log) + log.WithField("addr", cfg.Addr).Info("starting api") + return app.Listen(cfg.Addr) + } + } + if handlers.migrate == nil { + handlers.migrate = func(cmd *cobra.Command, args []string) error { + log := newLogger(cfg.LogLevel) + database, err := openDatabase(*cfg) + if err != nil { + return err + } + defer database.Close() + if err := service.ApplySchema(cmd.Context(), database, cfg.SchemaPath); err != nil { + return err + } + log.Info("schema applied") + return nil + } + } + if handlers.seed == nil { + handlers.seed = func(cmd *cobra.Command, args []string) error { + log := newLogger(cfg.LogLevel) + database, svc, err := openService(cmd.Context(), *cfg, log) + if err != nil { + return err + } + defer database.Close() + if err := svc.Seed(cmd.Context()); err != nil { + return err + } + log.Info("seed complete") + return nil + } + } + + var root = &cobra.Command{ + Use: "smilefirst", + Short: "Smile First oral-care booking service", + } + root.PersistentFlags().StringVar(&cfg.Addr, "addr", cfg.Addr, "HTTP listen address") + root.PersistentFlags().StringVar(&cfg.DatabasePath, "db", cfg.DatabasePath, "SQLite database path") + root.PersistentFlags().StringVar(&cfg.SchemaPath, "schema", cfg.SchemaPath, "schema SQL path") + root.PersistentFlags().StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "log level") + + root.AddCommand(&cobra.Command{ + Use: "serve", + Short: "Run the HTTP API server", + RunE: handlers.serve, + }) + root.AddCommand(&cobra.Command{ + Use: "migrate", + Short: "Apply SQLite schema", + RunE: handlers.migrate, + }) + root.AddCommand(&cobra.Command{ + Use: "seed", + Short: "Insert default categories, projects, and schedule slots", + RunE: handlers.seed, + }) + root.RunE = handlers.serve + return root +} + +func openService(ctx context.Context, cfg config.Config, log *logrus.Logger) (*sql.DB, *service.Service, error) { + database, err := openDatabase(cfg) + if err != nil { + return nil, nil, err + } + if err := service.ApplySchema(ctx, database, cfg.SchemaPath); err != nil { + database.Close() + return nil, nil, err + } + svc := service.New(database, log) + if err := svc.Seed(ctx); err != nil { + database.Close() + return nil, nil, err + } + return database, svc, nil +} + +func openDatabase(cfg config.Config) (*sql.DB, error) { + if err := service.EnsureDatabaseDir(cfg.DatabasePath); err != nil { + return nil, err + } + dsn := cfg.DatabasePath + if dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") { + dsn = "file:" + dsn + } + if dsn != ":memory:" { + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + dsn += sep + "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" + } + database, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + database.SetMaxOpenConns(1) + if err := database.Ping(); err != nil { + database.Close() + return nil, err + } + return database, nil +} + +func newLogger(level string) *logrus.Logger { + log := logrus.New() + log.SetFormatter(&logrus.JSONFormatter{}) + parsed, err := logrus.ParseLevel(level) + if err != nil { + parsed = logrus.InfoLevel + } + log.SetLevel(parsed) + return log +} diff --git a/backend/cmd/smilefirst/main_test.go b/backend/cmd/smilefirst/main_test.go new file mode 100644 index 0000000..ad546b1 --- /dev/null +++ b/backend/cmd/smilefirst/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/spf13/cobra" +) + +func TestRootCommandDefaultsToServe(t *testing.T) { + cfg := config.Config{ + Addr: ":9800", + DatabasePath: ":memory:", + SchemaPath: "migrations/schema.sql", + LogLevel: "info", + } + var calls []string + handlers := commandHandlers{ + serve: func(cmd *cobra.Command, args []string) error { + calls = append(calls, "serve") + return nil + }, + migrate: func(cmd *cobra.Command, args []string) error { + calls = append(calls, "migrate") + return nil + }, + seed: func(cmd *cobra.Command, args []string) error { + calls = append(calls, "seed") + return nil + }, + } + + cmd := newRootCommand(&cfg, handlers) + cmd.SetArgs([]string{"--addr", "127.0.0.1:18080"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute root command: %v", err) + } + if len(calls) != 1 || calls[0] != "serve" { + t.Fatalf("calls = %v, want [serve]", calls) + } + if cfg.Addr != "127.0.0.1:18080" { + t.Fatalf("cfg.Addr = %q, want flag value", cfg.Addr) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..e0c97d1 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,39 @@ +module github.com/rogeecn/wxapp-kouqiang-guahao/backend + +go 1.26.4 + +require ( + github.com/coreos/go-oidc/v3 v3.19.0 + github.com/gofiber/fiber/v3 v3.3.0 + github.com/sirupsen/logrus v1.9.4 + github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..82701e1 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,110 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8= +github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs= +github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..2227495 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,167 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Config struct { + Addr string + DatabasePath string + SchemaPath string + LogLevel string + AllowOrigins string + UploadDir string + WeChatAppID string + WeChatAppSecret string + WeChatAPIBase string + AdminAuthMode string + AdminSessionSecret string + AdminPasswordChangeRedirectURL string + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURI string + OIDCScopes string +} + +const ( + AdminAuthModeNone = "none" + AdminAuthModeOIDC = "oidc" +) + +func Load() Config { + loadEnvFiles() + return Config{ + Addr: env("GUAHAO_ADDR", ":9800"), + DatabasePath: env("GUAHAO_DB_PATH", "data/smilefirst.db"), + SchemaPath: env("GUAHAO_SCHEMA_PATH", "migrations/schema.sql"), + LogLevel: env("GUAHAO_LOG_LEVEL", "info"), + AllowOrigins: env("GUAHAO_ALLOW_ORIGINS", "*"), + UploadDir: env("GUAHAO_UPLOAD_DIR", "data/uploads"), + WeChatAppID: env("GUAHAO_WECHAT_APPID", ""), + WeChatAppSecret: env("GUAHAO_WECHAT_SECRET", ""), + WeChatAPIBase: env("GUAHAO_WECHAT_API_BASE", "https://api.weixin.qq.com"), + AdminAuthMode: env("GUAHAO_ADMIN_AUTH_MODE", AdminAuthModeOIDC), + AdminSessionSecret: env("GUAHAO_ADMIN_SESSION_SECRET", ""), + AdminPasswordChangeRedirectURL: env("GUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL", "https://gh.yqbmb.com/admin/price-inquiries"), + OIDCIssuer: env("GUAHAO_OIDC_ISSUER", ""), + OIDCClientID: env("GUAHAO_OIDC_CLIENT_ID", ""), + OIDCClientSecret: env("GUAHAO_OIDC_CLIENT_SECRET", ""), + OIDCRedirectURI: env("GUAHAO_OIDC_REDIRECT_URI", ""), + OIDCScopes: env("GUAHAO_OIDC_SCOPES", "openid profile email"), + } +} + +func (c Config) AdminAuthModeValue() string { + mode := strings.ToLower(strings.TrimSpace(c.AdminAuthMode)) + if mode == "" { + return AdminAuthModeOIDC + } + return mode +} + +func (c Config) AdminAuthDisabled() bool { + return c.AdminAuthModeValue() == AdminAuthModeNone +} + +func (c Config) AdminOIDCLoginEnabled() bool { + return c.AdminAuthModeValue() == AdminAuthModeOIDC +} + +func (c Config) ValidateAdminAuth() error { + switch c.AdminAuthModeValue() { + case AdminAuthModeNone: + return nil + case AdminAuthModeOIDC: + return c.validateOIDCConfiguration() + default: + return fmt.Errorf("invalid admin auth mode %q: use %s or %s", c.AdminAuthMode, AdminAuthModeNone, AdminAuthModeOIDC) + } +} + +func (c Config) validateOIDCConfiguration() error { + var missing []string + if strings.TrimSpace(c.OIDCIssuer) == "" { + missing = append(missing, "GUAHAO_OIDC_ISSUER") + } + if strings.TrimSpace(c.OIDCClientID) == "" { + missing = append(missing, "GUAHAO_OIDC_CLIENT_ID") + } + if c.OIDCClientSecret == "" { + missing = append(missing, "GUAHAO_OIDC_CLIENT_SECRET") + } + if strings.TrimSpace(c.OIDCRedirectURI) == "" { + missing = append(missing, "GUAHAO_OIDC_REDIRECT_URI") + } + if c.AdminSessionSecret == "" { + missing = append(missing, "GUAHAO_ADMIN_SESSION_SECRET") + } + if len(missing) > 0 { + return fmt.Errorf("missing OIDC admin configuration: set %s in .env or environment", strings.Join(missing, ", ")) + } + return nil +} + +func env(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func loadEnvFiles() { + if explicit := strings.TrimSpace(os.Getenv("GUAHAO_ENV_FILE")); explicit != "" { + _ = loadEnvFile(explicit) + return + } + for _, path := range []string{".env", filepath.Join("..", ".env")} { + _ = loadEnvFile(path) + } +} + +func loadEnvFile(path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + _ = os.Setenv(key, cleanEnvValue(value)) + } + return scanner.Err() +} + +func cleanEnvValue(value string) string { + value = strings.TrimSpace(value) + if len(value) < 2 { + return value + } + quote := value[0] + if (quote == '\'' || quote == '"') && value[len(value)-1] == quote { + return value[1 : len(value)-1] + } + return value +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..3f5f3c6 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadReadsAdminAuthFromDotEnv(t *testing.T) { + restoreEnv := clearAdminEnv(t) + restoreWorkingDir := chdir(t, t.TempDir()) + t.Cleanup(restoreEnv) + t.Cleanup(restoreWorkingDir) + + if err := os.WriteFile(".env", []byte("GUAHAO_ADMIN_AUTH_MODE=none\nGUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL=https://gh.yqbmb.com/admin/price-inquiries\nGUAHAO_OIDC_CLIENT_SECRET='local-secret'\n"), 0o600); err != nil { + t.Fatalf("write .env: %v", err) + } + + cfg := Load() + if cfg.AdminAuthMode != "none" { + t.Fatalf("AdminAuthMode = %q, want none", cfg.AdminAuthMode) + } + if cfg.OIDCClientSecret != "local-secret" { + t.Fatalf("OIDCClientSecret = %q, want local-secret", cfg.OIDCClientSecret) + } + if cfg.AdminPasswordChangeRedirectURL != "https://gh.yqbmb.com/admin/price-inquiries" { + t.Fatalf("AdminPasswordChangeRedirectURL = %q, want production price inquiries URL", cfg.AdminPasswordChangeRedirectURL) + } +} + +func TestValidateAdminAuthNoneDoesNotRequireOIDC(t *testing.T) { + restoreEnv := clearAdminEnv(t) + restoreWorkingDir := chdir(t, t.TempDir()) + t.Cleanup(restoreEnv) + t.Cleanup(restoreWorkingDir) + + cfg := Config{AdminAuthMode: AdminAuthModeNone} + if err := cfg.ValidateAdminAuth(); err != nil { + t.Fatalf("ValidateAdminAuth() error = %v, want nil", err) + } +} + +func TestValidateAdminAuthOIDCRequiresConfiguration(t *testing.T) { + cfg := Config{AdminAuthMode: AdminAuthModeOIDC} + if err := cfg.ValidateAdminAuth(); err == nil { + t.Fatal("ValidateAdminAuth() error = nil, want missing OIDC configuration error") + } + + cfg = Config{ + AdminAuthMode: AdminAuthModeOIDC, + AdminSessionSecret: "session-secret", + OIDCIssuer: "https://auth.example.com/oidc", + OIDCClientID: "client-id", + OIDCClientSecret: "client-secret", + OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback", + } + if err := cfg.ValidateAdminAuth(); err != nil { + t.Fatalf("ValidateAdminAuth() error = %v, want nil", err) + } +} + +func clearAdminEnv(t *testing.T) func() { + t.Helper() + keys := []string{ + "GUAHAO_ADMIN_AUTH_MODE", + "GUAHAO_ADMIN_SESSION_SECRET", + "GUAHAO_ADMIN_PASSWORD_CHANGE_REDIRECT_URL", + "GUAHAO_WECHAT_APPID", + "GUAHAO_WECHAT_SECRET", + "GUAHAO_WECHAT_API_BASE", + "GUAHAO_OIDC_ISSUER", + "GUAHAO_OIDC_CLIENT_ID", + "GUAHAO_OIDC_CLIENT_SECRET", + "GUAHAO_OIDC_REDIRECT_URI", + "GUAHAO_OIDC_SCOPES", + "GUAHAO_ENV_FILE", + } + type previous struct { + value string + ok bool + } + previousValues := make(map[string]previous, len(keys)) + for _, key := range keys { + value, ok := os.LookupEnv(key) + previousValues[key] = previous{value: value, ok: ok} + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } + return func() { + for _, key := range keys { + prev := previousValues[key] + if prev.ok { + _ = os.Setenv(key, prev.value) + } else { + _ = os.Unsetenv(key) + } + } + } +} + +func chdir(t *testing.T, dir string) func() { + t.Helper() + abs, err := filepath.Abs(dir) + if err != nil { + t.Fatalf("resolve temp dir: %v", err) + } + previous, err := os.Getwd() + if err != nil { + t.Fatalf("get working dir: %v", err) + } + if err := os.Chdir(abs); err != nil { + t.Fatalf("chdir: %v", err) + } + return func() { + _ = os.Chdir(previous) + } +} diff --git a/backend/internal/db/app_config.sql.go b/backend/internal/db/app_config.sql.go new file mode 100644 index 0000000..d36312d --- /dev/null +++ b/backend/internal/db/app_config.sql.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: app_config.sql + +package db + +import ( + "context" +) + +const getAppConfig = `-- name: GetAppConfig :one +SELECT config_key, config_value, updated_at FROM app_config +WHERE config_key = ? +` + +func (q *Queries) GetAppConfig(ctx context.Context, configKey string) (AppConfig, error) { + row := q.db.QueryRowContext(ctx, getAppConfig, configKey) + var i AppConfig + err := row.Scan(&i.ConfigKey, &i.ConfigValue, &i.UpdatedAt) + return i, err +} + +const upsertAppConfig = `-- name: UpsertAppConfig :one +INSERT INTO app_config (config_key, config_value) +VALUES (?, ?) +ON CONFLICT(config_key) DO UPDATE +SET config_value = excluded.config_value, + updated_at = CURRENT_TIMESTAMP +RETURNING config_key, config_value, updated_at +` + +type UpsertAppConfigParams struct { + ConfigKey string `json:"config_key"` + ConfigValue string `json:"config_value"` +} + +func (q *Queries) UpsertAppConfig(ctx context.Context, arg UpsertAppConfigParams) (AppConfig, error) { + row := q.db.QueryRowContext(ctx, upsertAppConfig, arg.ConfigKey, arg.ConfigValue) + var i AppConfig + err := row.Scan(&i.ConfigKey, &i.ConfigValue, &i.UpdatedAt) + return i, err +} diff --git a/backend/internal/db/audit_logs.sql.go b/backend/internal/db/audit_logs.sql.go new file mode 100644 index 0000000..42def43 --- /dev/null +++ b/backend/internal/db/audit_logs.sql.go @@ -0,0 +1,52 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: audit_logs.sql + +package db + +import ( + "context" +) + +const createConfigAuditLog = `-- name: CreateConfigAuditLog :one +INSERT INTO config_audit_logs ( + id, admin_id, target_type, target_id, action, before_json, after_json +) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id, admin_id, target_type, target_id, "action", before_json, after_json, created_at +` + +type CreateConfigAuditLogParams struct { + ID string `json:"id"` + AdminID string `json:"admin_id"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + Action string `json:"action"` + BeforeJson string `json:"before_json"` + AfterJson string `json:"after_json"` +} + +func (q *Queries) CreateConfigAuditLog(ctx context.Context, arg CreateConfigAuditLogParams) (ConfigAuditLog, error) { + row := q.db.QueryRowContext(ctx, createConfigAuditLog, + arg.ID, + arg.AdminID, + arg.TargetType, + arg.TargetID, + arg.Action, + arg.BeforeJson, + arg.AfterJson, + ) + var i ConfigAuditLog + err := row.Scan( + &i.ID, + &i.AdminID, + &i.TargetType, + &i.TargetID, + &i.Action, + &i.BeforeJson, + &i.AfterJson, + &i.CreatedAt, + ) + return i, err +} diff --git a/backend/internal/db/bookings.sql.go b/backend/internal/db/bookings.sql.go new file mode 100644 index 0000000..3f69068 --- /dev/null +++ b/backend/internal/db/bookings.sql.go @@ -0,0 +1,282 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: bookings.sql + +package db + +import ( + "context" + "database/sql" +) + +const cancelBooking = `-- name: CancelBooking :one +UPDATE bookings +SET status = 'cancelled_by_user', updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND phone = ? AND status IN ('pending', 'confirmed') +RETURNING id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at +` + +type CancelBookingParams struct { + ID string `json:"id"` + Phone string `json:"phone"` +} + +func (q *Queries) CancelBooking(ctx context.Context, arg CancelBookingParams) (Booking, error) { + row := q.db.QueryRowContext(ctx, cancelBooking, arg.ID, arg.Phone) + var i Booking + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const countBookingsByProject = `-- name: CountBookingsByProject :one +SELECT COUNT(*) FROM bookings +WHERE project_id = ? +` + +func (q *Queries) CountBookingsByProject(ctx context.Context, projectID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countBookingsByProject, projectID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createBooking = `-- name: CreateBooking :one +INSERT INTO bookings (id, user_id, project_id, slot_id, phone, status, remark, confirm_token) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at +` + +type CreateBookingParams struct { + ID string `json:"id"` + UserID sql.NullString `json:"user_id"` + ProjectID string `json:"project_id"` + SlotID string `json:"slot_id"` + Phone string `json:"phone"` + Status string `json:"status"` + Remark string `json:"remark"` + ConfirmToken string `json:"confirm_token"` +} + +func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (Booking, error) { + row := q.db.QueryRowContext(ctx, createBooking, + arg.ID, + arg.UserID, + arg.ProjectID, + arg.SlotID, + arg.Phone, + arg.Status, + arg.Remark, + arg.ConfirmToken, + ) + var i Booking + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getBooking = `-- name: GetBooking :one +SELECT id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at FROM bookings +WHERE id = ? +` + +func (q *Queries) GetBooking(ctx context.Context, id string) (Booking, error) { + row := q.db.QueryRowContext(ctx, getBooking, id) + var i Booking + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listBookings = `-- name: ListBookings :many +SELECT id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at FROM bookings +ORDER BY created_at DESC +LIMIT ? OFFSET ? +` + +type ListBookingsParams struct { + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListBookings(ctx context.Context, arg ListBookingsParams) ([]Booking, error) { + rows, err := q.db.QueryContext(ctx, listBookings, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Booking + for rows.Next() { + var i Booking + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBookingsByPhone = `-- name: ListBookingsByPhone :many +SELECT id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at FROM bookings +WHERE phone = ? +ORDER BY created_at DESC +` + +func (q *Queries) ListBookingsByPhone(ctx context.Context, phone string) ([]Booking, error) { + rows, err := q.db.QueryContext(ctx, listBookingsByPhone, phone) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Booking + for rows.Next() { + var i Booking + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBookingsByStatus = `-- name: ListBookingsByStatus :many +SELECT id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at FROM bookings +WHERE status = ? +ORDER BY created_at DESC +LIMIT ? OFFSET ? +` + +type ListBookingsByStatusParams struct { + Status string `json:"status"` + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListBookingsByStatus(ctx context.Context, arg ListBookingsByStatusParams) ([]Booking, error) { + rows, err := q.db.QueryContext(ctx, listBookingsByStatus, arg.Status, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Booking + for rows.Next() { + var i Booking + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateBookingStatus = `-- name: UpdateBookingStatus :one +UPDATE bookings +SET status = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, user_id, project_id, slot_id, phone, status, remark, confirm_token, created_at, updated_at +` + +type UpdateBookingStatusParams struct { + Status string `json:"status"` + ID string `json:"id"` +} + +func (q *Queries) UpdateBookingStatus(ctx context.Context, arg UpdateBookingStatusParams) (Booking, error) { + row := q.db.QueryRowContext(ctx, updateBookingStatus, arg.Status, arg.ID) + var i Booking + err := row.Scan( + &i.ID, + &i.UserID, + &i.ProjectID, + &i.SlotID, + &i.Phone, + &i.Status, + &i.Remark, + &i.ConfirmToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/db/categories.sql.go b/backend/internal/db/categories.sql.go new file mode 100644 index 0000000..ff2c3cb --- /dev/null +++ b/backend/internal/db/categories.sql.go @@ -0,0 +1,167 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: categories.sql + +package db + +import ( + "context" +) + +const countCategories = `-- name: CountCategories :one +SELECT COUNT(*) FROM project_categories +` + +func (q *Queries) CountCategories(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countCategories) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createCategory = `-- name: CreateCategory :one +INSERT INTO project_categories (id, name, sort, status) +VALUES (?, ?, ?, ?) +RETURNING id, name, sort, status, created_at, updated_at +` + +type CreateCategoryParams struct { + ID string `json:"id"` + Name string `json:"name"` + Sort int64 `json:"sort"` + Status string `json:"status"` +} + +func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (ProjectCategory, error) { + row := q.db.QueryRowContext(ctx, createCategory, + arg.ID, + arg.Name, + arg.Sort, + arg.Status, + ) + var i ProjectCategory + err := row.Scan( + &i.ID, + &i.Name, + &i.Sort, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteCategory = `-- name: DeleteCategory :exec +DELETE FROM project_categories +WHERE id = ? +` + +func (q *Queries) DeleteCategory(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteCategory, id) + return err +} + +const listActiveCategories = `-- name: ListActiveCategories :many +SELECT id, name, sort, status, created_at, updated_at FROM project_categories +WHERE status = 'active' +ORDER BY sort ASC, created_at ASC +` + +func (q *Queries) ListActiveCategories(ctx context.Context) ([]ProjectCategory, error) { + rows, err := q.db.QueryContext(ctx, listActiveCategories) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectCategory + for rows.Next() { + var i ProjectCategory + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Sort, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCategories = `-- name: ListCategories :many +SELECT id, name, sort, status, created_at, updated_at FROM project_categories +ORDER BY sort ASC, created_at ASC +` + +func (q *Queries) ListCategories(ctx context.Context) ([]ProjectCategory, error) { + rows, err := q.db.QueryContext(ctx, listCategories) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectCategory + for rows.Next() { + var i ProjectCategory + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Sort, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCategory = `-- name: UpdateCategory :one +UPDATE project_categories +SET name = ?, sort = ?, status = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, name, sort, status, created_at, updated_at +` + +type UpdateCategoryParams struct { + Name string `json:"name"` + Sort int64 `json:"sort"` + Status string `json:"status"` + ID string `json:"id"` +} + +func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (ProjectCategory, error) { + row := q.db.QueryRowContext(ctx, updateCategory, + arg.Name, + arg.Sort, + arg.Status, + arg.ID, + ) + var i ProjectCategory + err := row.Scan( + &i.ID, + &i.Name, + &i.Sort, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/internal/db/models.go b/backend/internal/db/models.go new file mode 100644 index 0000000..b17b78a --- /dev/null +++ b/backend/internal/db/models.go @@ -0,0 +1,106 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type AppConfig struct { + ConfigKey string `json:"config_key"` + ConfigValue string `json:"config_value"` + UpdatedAt string `json:"updated_at"` +} + +type Booking struct { + ID string `json:"id"` + UserID sql.NullString `json:"user_id"` + ProjectID string `json:"project_id"` + SlotID string `json:"slot_id"` + Phone string `json:"phone"` + Status string `json:"status"` + Remark string `json:"remark"` + ConfirmToken string `json:"confirm_token"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ConfigAuditLog struct { + ID string `json:"id"` + AdminID string `json:"admin_id"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + Action string `json:"action"` + BeforeJson string `json:"before_json"` + AfterJson string `json:"after_json"` + CreatedAt string `json:"created_at"` +} + +type PriceInquiry struct { + ID string `json:"id"` + UserID sql.NullString `json:"user_id"` + Phone string `json:"phone"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + Remark string `json:"remark"` + LastSubmittedAt string `json:"last_submitted_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Project struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + PriceText string `json:"price_text"` + Image string `json:"image"` + Status string `json:"status"` + Sort int64 `json:"sort"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ProjectCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Sort int64 `json:"sort"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ScheduleDay struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Date string `json:"date"` + Status string `json:"status"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type TimeSlot struct { + ID string `json:"id"` + ScheduleDayID string `json:"schedule_day_id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type User struct { + ID string `json:"id"` + Openid string `json:"openid"` + Unionid sql.NullString `json:"unionid"` + Phone sql.NullString `json:"phone"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastLoginAt string `json:"last_login_at"` +} diff --git a/backend/internal/db/price_inquiries.sql.go b/backend/internal/db/price_inquiries.sql.go new file mode 100644 index 0000000..012364d --- /dev/null +++ b/backend/internal/db/price_inquiries.sql.go @@ -0,0 +1,232 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: price_inquiries.sql + +package db + +import ( + "context" + "database/sql" +) + +const countPriceInquiries = `-- name: CountPriceInquiries :one +SELECT COUNT(*) FROM price_inquiries +WHERE ( + ?1 = '' + OR province LIKE '%' || ?1 || '%' + OR city LIKE '%' || ?1 || '%' + OR district LIKE '%' || ?1 || '%' +) +AND (?2 = '' OR phone LIKE '%' || ?2 || '%') +AND (?3 = '' OR status = ?3) +AND ( + (?4 = '' AND ?5 = '') + OR ( + (?4 = '' OR created_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR created_at <= ?5 || ' 23:59:59') + ) + OR ( + (?4 = '' OR updated_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR updated_at <= ?5 || ' 23:59:59') + ) + OR ( + (?4 = '' OR last_submitted_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR last_submitted_at <= ?5 || ' 23:59:59') + ) +) +` + +type CountPriceInquiriesParams struct { + Area interface{} `json:"area"` + Phone interface{} `json:"phone"` + Status interface{} `json:"status"` + TimeFrom interface{} `json:"time_from"` + TimeTo interface{} `json:"time_to"` +} + +func (q *Queries) CountPriceInquiries(ctx context.Context, arg CountPriceInquiriesParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countPriceInquiries, + arg.Area, + arg.Phone, + arg.Status, + arg.TimeFrom, + arg.TimeTo, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listPriceInquiries = `-- name: ListPriceInquiries :many +SELECT id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at FROM price_inquiries +WHERE ( + ?1 = '' + OR province LIKE '%' || ?1 || '%' + OR city LIKE '%' || ?1 || '%' + OR district LIKE '%' || ?1 || '%' +) +AND (?2 = '' OR phone LIKE '%' || ?2 || '%') +AND (?3 = '' OR status = ?3) +AND ( + (?4 = '' AND ?5 = '') + OR ( + (?4 = '' OR created_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR created_at <= ?5 || ' 23:59:59') + ) + OR ( + (?4 = '' OR updated_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR updated_at <= ?5 || ' 23:59:59') + ) + OR ( + (?4 = '' OR last_submitted_at >= ?4 || ' 00:00:00') + AND (?5 = '' OR last_submitted_at <= ?5 || ' 23:59:59') + ) +) +ORDER BY last_submitted_at DESC, updated_at DESC, created_at DESC +LIMIT ?7 OFFSET ?6 +` + +type ListPriceInquiriesParams struct { + Area interface{} `json:"area"` + Phone interface{} `json:"phone"` + Status interface{} `json:"status"` + TimeFrom interface{} `json:"time_from"` + TimeTo interface{} `json:"time_to"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` +} + +func (q *Queries) ListPriceInquiries(ctx context.Context, arg ListPriceInquiriesParams) ([]PriceInquiry, error) { + rows, err := q.db.QueryContext(ctx, listPriceInquiries, + arg.Area, + arg.Phone, + arg.Status, + arg.TimeFrom, + arg.TimeTo, + arg.Offset, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PriceInquiry + for rows.Next() { + var i PriceInquiry + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Phone, + &i.Province, + &i.City, + &i.District, + &i.ProjectName, + &i.Status, + &i.Remark, + &i.LastSubmittedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePriceInquiry = `-- name: UpdatePriceInquiry :one +UPDATE price_inquiries +SET status = ?, remark = ?, updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +WHERE id = ? +RETURNING id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at +` + +type UpdatePriceInquiryParams struct { + Status string `json:"status"` + Remark string `json:"remark"` + ID string `json:"id"` +} + +func (q *Queries) UpdatePriceInquiry(ctx context.Context, arg UpdatePriceInquiryParams) (PriceInquiry, error) { + row := q.db.QueryRowContext(ctx, updatePriceInquiry, arg.Status, arg.Remark, arg.ID) + var i PriceInquiry + err := row.Scan( + &i.ID, + &i.UserID, + &i.Phone, + &i.Province, + &i.City, + &i.District, + &i.ProjectName, + &i.Status, + &i.Remark, + &i.LastSubmittedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertPriceInquiry = `-- name: UpsertPriceInquiry :one +INSERT INTO price_inquiries ( + id, user_id, phone, province, city, district, project_name, status, + last_submitted_at, created_at, updated_at +) +VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +) +ON CONFLICT(phone) DO UPDATE SET + last_submitted_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +RETURNING id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at +` + +type UpsertPriceInquiryParams struct { + ID string `json:"id"` + UserID sql.NullString `json:"user_id"` + Phone string `json:"phone"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + ProjectName string `json:"project_name"` + Status string `json:"status"` +} + +func (q *Queries) UpsertPriceInquiry(ctx context.Context, arg UpsertPriceInquiryParams) (PriceInquiry, error) { + row := q.db.QueryRowContext(ctx, upsertPriceInquiry, + arg.ID, + arg.UserID, + arg.Phone, + arg.Province, + arg.City, + arg.District, + arg.ProjectName, + arg.Status, + ) + var i PriceInquiry + err := row.Scan( + &i.ID, + &i.UserID, + &i.Phone, + &i.Province, + &i.City, + &i.District, + &i.ProjectName, + &i.Status, + &i.Remark, + &i.LastSubmittedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/db/projects.sql.go b/backend/internal/db/projects.sql.go new file mode 100644 index 0000000..6f26361 --- /dev/null +++ b/backend/internal/db/projects.sql.go @@ -0,0 +1,272 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: projects.sql + +package db + +import ( + "context" +) + +const countProjectsByCategory = `-- name: CountProjectsByCategory :one +SELECT COUNT(*) FROM projects +WHERE category_id = ? +` + +func (q *Queries) CountProjectsByCategory(ctx context.Context, categoryID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countProjectsByCategory, categoryID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createProject = `-- name: CreateProject :one +INSERT INTO projects ( + id, category_id, name, subtitle, price_text, image, status, sort +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at +` + +type CreateProjectParams struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + PriceText string `json:"price_text"` + Image string `json:"image"` + Status string `json:"status"` + Sort int64 `json:"sort"` +} + +func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { + row := q.db.QueryRowContext(ctx, createProject, + arg.ID, + arg.CategoryID, + arg.Name, + arg.Subtitle, + arg.PriceText, + arg.Image, + arg.Status, + arg.Sort, + ) + var i Project + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteProject = `-- name: DeleteProject :exec +DELETE FROM projects +WHERE id = ? +` + +func (q *Queries) DeleteProject(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteProject, id) + return err +} + +const getProject = `-- name: GetProject :one +SELECT id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at FROM projects +WHERE id = ? +` + +func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRowContext(ctx, getProject, id) + var i Project + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listActiveProjects = `-- name: ListActiveProjects :many +SELECT id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at FROM projects +WHERE status = 'active' +ORDER BY sort ASC, created_at ASC +` + +func (q *Queries) ListActiveProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listActiveProjects) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listActiveProjectsByCategory = `-- name: ListActiveProjectsByCategory :many +SELECT id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at FROM projects +WHERE category_id = ? AND status = 'active' +ORDER BY sort ASC, created_at ASC +` + +func (q *Queries) ListActiveProjectsByCategory(ctx context.Context, categoryID string) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listActiveProjectsByCategory, categoryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjects = `-- name: ListProjects :many +SELECT id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at FROM projects +ORDER BY sort ASC, created_at ASC +` + +func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listProjects) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateProject = `-- name: UpdateProject :one +UPDATE projects +SET category_id = ?, + name = ?, + subtitle = ?, + price_text = ?, + image = ?, + status = ?, + sort = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, category_id, name, subtitle, price_text, image, status, sort, created_at, updated_at +` + +type UpdateProjectParams struct { + CategoryID string `json:"category_id"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + PriceText string `json:"price_text"` + Image string `json:"image"` + Status string `json:"status"` + Sort int64 `json:"sort"` + ID string `json:"id"` +} + +func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) { + row := q.db.QueryRowContext(ctx, updateProject, + arg.CategoryID, + arg.Name, + arg.Subtitle, + arg.PriceText, + arg.Image, + arg.Status, + arg.Sort, + arg.ID, + ) + var i Project + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Subtitle, + &i.PriceText, + &i.Image, + &i.Status, + &i.Sort, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/db/schedules.sql.go b/backend/internal/db/schedules.sql.go new file mode 100644 index 0000000..e8e0a56 --- /dev/null +++ b/backend/internal/db/schedules.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: schedules.sql + +package db + +import ( + "context" + "database/sql" +) + +const createScheduleDay = `-- name: CreateScheduleDay :one +INSERT INTO schedule_days (id, project_id, date, status, note) +VALUES (?, ?, ?, ?, ?) +RETURNING id, project_id, date, status, note, created_at, updated_at +` + +type CreateScheduleDayParams struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Date string `json:"date"` + Status string `json:"status"` + Note string `json:"note"` +} + +func (q *Queries) CreateScheduleDay(ctx context.Context, arg CreateScheduleDayParams) (ScheduleDay, error) { + row := q.db.QueryRowContext(ctx, createScheduleDay, + arg.ID, + arg.ProjectID, + arg.Date, + arg.Status, + arg.Note, + ) + var i ScheduleDay + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Date, + &i.Status, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createTimeSlot = `-- name: CreateTimeSlot :one +INSERT INTO time_slots (id, schedule_day_id, start_time, end_time, status) +VALUES (?, ?, ?, ?, ?) +RETURNING id, schedule_day_id, start_time, end_time, status, created_at, updated_at +` + +type CreateTimeSlotParams struct { + ID string `json:"id"` + ScheduleDayID string `json:"schedule_day_id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Status string `json:"status"` +} + +func (q *Queries) CreateTimeSlot(ctx context.Context, arg CreateTimeSlotParams) (TimeSlot, error) { + row := q.db.QueryRowContext(ctx, createTimeSlot, + arg.ID, + arg.ScheduleDayID, + arg.StartTime, + arg.EndTime, + arg.Status, + ) + var i TimeSlot + err := row.Scan( + &i.ID, + &i.ScheduleDayID, + &i.StartTime, + &i.EndTime, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteScheduleDaysByProject = `-- name: DeleteScheduleDaysByProject :exec +DELETE FROM schedule_days +WHERE project_id = ? +` + +func (q *Queries) DeleteScheduleDaysByProject(ctx context.Context, projectID string) error { + _, err := q.db.ExecContext(ctx, deleteScheduleDaysByProject, projectID) + return err +} + +const deleteTimeSlotsByProject = `-- name: DeleteTimeSlotsByProject :exec +DELETE FROM time_slots +WHERE schedule_day_id IN ( + SELECT id FROM schedule_days + WHERE project_id = ? +) +` + +func (q *Queries) DeleteTimeSlotsByProject(ctx context.Context, projectID string) error { + _, err := q.db.ExecContext(ctx, deleteTimeSlotsByProject, projectID) + return err +} + +const getScheduleDay = `-- name: GetScheduleDay :one +SELECT id, project_id, date, status, note, created_at, updated_at FROM schedule_days +WHERE id = ? +` + +func (q *Queries) GetScheduleDay(ctx context.Context, id string) (ScheduleDay, error) { + row := q.db.QueryRowContext(ctx, getScheduleDay, id) + var i ScheduleDay + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Date, + &i.Status, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTimeSlot = `-- name: GetTimeSlot :one +SELECT id, schedule_day_id, start_time, end_time, status, created_at, updated_at FROM time_slots +WHERE id = ? +` + +func (q *Queries) GetTimeSlot(ctx context.Context, id string) (TimeSlot, error) { + row := q.db.QueryRowContext(ctx, getTimeSlot, id) + var i TimeSlot + err := row.Scan( + &i.ID, + &i.ScheduleDayID, + &i.StartTime, + &i.EndTime, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listScheduleDaysByProjectMonth = `-- name: ListScheduleDaysByProjectMonth :many +SELECT id, project_id, date, status, note, created_at, updated_at FROM schedule_days +WHERE project_id = ?1 AND date LIKE ?2 || '%' +ORDER BY date ASC +` + +type ListScheduleDaysByProjectMonthParams struct { + ProjectID string `json:"project_id"` + Month sql.NullString `json:"month"` +} + +func (q *Queries) ListScheduleDaysByProjectMonth(ctx context.Context, arg ListScheduleDaysByProjectMonthParams) ([]ScheduleDay, error) { + rows, err := q.db.QueryContext(ctx, listScheduleDaysByProjectMonth, arg.ProjectID, arg.Month) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ScheduleDay + for rows.Next() { + var i ScheduleDay + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Date, + &i.Status, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTimeSlotsByDay = `-- name: ListTimeSlotsByDay :many +SELECT id, schedule_day_id, start_time, end_time, status, created_at, updated_at FROM time_slots +WHERE schedule_day_id = ? +ORDER BY start_time ASC +` + +func (q *Queries) ListTimeSlotsByDay(ctx context.Context, scheduleDayID string) ([]TimeSlot, error) { + rows, err := q.db.QueryContext(ctx, listTimeSlotsByDay, scheduleDayID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TimeSlot + for rows.Next() { + var i TimeSlot + if err := rows.Scan( + &i.ID, + &i.ScheduleDayID, + &i.StartTime, + &i.EndTime, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateScheduleDay = `-- name: UpdateScheduleDay :one +UPDATE schedule_days +SET status = ?, note = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, project_id, date, status, note, created_at, updated_at +` + +type UpdateScheduleDayParams struct { + Status string `json:"status"` + Note string `json:"note"` + ID string `json:"id"` +} + +func (q *Queries) UpdateScheduleDay(ctx context.Context, arg UpdateScheduleDayParams) (ScheduleDay, error) { + row := q.db.QueryRowContext(ctx, updateScheduleDay, arg.Status, arg.Note, arg.ID) + var i ScheduleDay + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Date, + &i.Status, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateTimeSlot = `-- name: UpdateTimeSlot :one +UPDATE time_slots +SET start_time = ?, + end_time = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, schedule_day_id, start_time, end_time, status, created_at, updated_at +` + +type UpdateTimeSlotParams struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Status string `json:"status"` + ID string `json:"id"` +} + +func (q *Queries) UpdateTimeSlot(ctx context.Context, arg UpdateTimeSlotParams) (TimeSlot, error) { + row := q.db.QueryRowContext(ctx, updateTimeSlot, + arg.StartTime, + arg.EndTime, + arg.Status, + arg.ID, + ) + var i TimeSlot + err := row.Scan( + &i.ID, + &i.ScheduleDayID, + &i.StartTime, + &i.EndTime, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/db/users.sql.go b/backend/internal/db/users.sql.go new file mode 100644 index 0000000..a4cea55 --- /dev/null +++ b/backend/internal/db/users.sql.go @@ -0,0 +1,210 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package db + +import ( + "context" + "database/sql" +) + +const bindUserPhone = `-- name: BindUserPhone :one +UPDATE users +SET phone = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING id, openid, unionid, phone, created_at, updated_at, last_login_at +` + +type BindUserPhoneParams struct { + Phone sql.NullString `json:"phone"` + ID string `json:"id"` +} + +func (q *Queries) BindUserPhone(ctx context.Context, arg BindUserPhoneParams) (User, error) { + row := q.db.QueryRowContext(ctx, bindUserPhone, arg.Phone, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, openid, unionid, phone) +VALUES (?, ?, ?, ?) +RETURNING id, openid, unionid, phone, created_at, updated_at, last_login_at +` + +type CreateUserParams struct { + ID string `json:"id"` + Openid string `json:"openid"` + Unionid sql.NullString `json:"unionid"` + Phone sql.NullString `json:"phone"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.ID, + arg.Openid, + arg.Unionid, + arg.Phone, + ) + var i User + err := row.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, openid, unionid, phone, created_at, updated_at, last_login_at FROM users +WHERE id = ? +` + +func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ) + return i, err +} + +const getUserByOpenID = `-- name: GetUserByOpenID :one +SELECT id, openid, unionid, phone, created_at, updated_at, last_login_at FROM users +WHERE openid = ? +` + +func (q *Queries) GetUserByOpenID(ctx context.Context, openid string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByOpenID, openid) + var i User + err := row.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ) + return i, err +} + +const listBookingPhoneSummaries = `-- name: ListBookingPhoneSummaries :many +SELECT + phone, + COUNT(*) AS booking_count, + CAST(MAX(created_at) AS TEXT) AS last_booking_at +FROM bookings +WHERE phone <> '' +GROUP BY phone +ORDER BY last_booking_at DESC, phone ASC +` + +type ListBookingPhoneSummariesRow struct { + Phone string `json:"phone"` + BookingCount int64 `json:"booking_count"` + LastBookingAt string `json:"last_booking_at"` +} + +func (q *Queries) ListBookingPhoneSummaries(ctx context.Context) ([]ListBookingPhoneSummariesRow, error) { + rows, err := q.db.QueryContext(ctx, listBookingPhoneSummaries) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBookingPhoneSummariesRow + for rows.Next() { + var i ListBookingPhoneSummariesRow + if err := rows.Scan(&i.Phone, &i.BookingCount, &i.LastBookingAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersWithPhone = `-- name: ListUsersWithPhone :many +SELECT id, openid, unionid, phone, created_at, updated_at, last_login_at FROM users +WHERE phone IS NOT NULL AND phone <> '' +ORDER BY updated_at DESC, created_at DESC +` + +func (q *Queries) ListUsersWithPhone(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsersWithPhone) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchUserLogin = `-- name: TouchUserLogin :one +UPDATE users +SET last_login_at = CURRENT_TIMESTAMP +WHERE openid = ? +RETURNING id, openid, unionid, phone, created_at, updated_at, last_login_at +` + +func (q *Queries) TouchUserLogin(ctx context.Context, openid string) (User, error) { + row := q.db.QueryRowContext(ctx, touchUserLogin, openid) + var i User + err := row.Scan( + &i.ID, + &i.Openid, + &i.Unionid, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastLoginAt, + ) + return i, err +} diff --git a/backend/internal/httpapi/admin.go b/backend/internal/httpapi/admin.go new file mode 100644 index 0000000..53abb3b --- /dev/null +++ b/backend/internal/httpapi/admin.go @@ -0,0 +1,678 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gofiber/fiber/v3" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service" + "golang.org/x/oauth2" +) + +//go:embed templates/*.html +var adminTemplateFS embed.FS + +const ( + adminSessionCookie = "smilefirst_admin" + adminOIDCCookie = "smilefirst_oidc" + defaultAdminPage = "/admin/price-inquiries" + adminUserLocalKey = "adminUser" + defaultAdminPageSize = 50 +) + +type adminUI struct { + cfg config.Config + svc *service.Service + templates *template.Template + oidcMu sync.Mutex + oidcProvider *oidc.Provider + oauth2Config *oauth2.Config + oidcVerifier *oidc.IDTokenVerifier +} + +type adminPageData struct { + PageTitle string + Active string + Username string + Next string + Message string + Error string + OIDCLoginURL string + PasswordChangeURL string + HomeHeroImage string + Categories []service.CategoryWithProjects + PhoneRecords []service.AdminPhoneRecord + BookingRecords []service.AdminBookingRecord + PriceInquiryRecords []service.AdminPriceInquiryRecord + PhoneFilter string + AreaFilter string + StatusFilter string + TimeFromFilter string + TimeToFilter string + HasPriceInquiryFilter bool + PriceInquiryTotal int64 + PriceInquiryPage int + PriceInquiryPageSize int + PriceInquiryTotalPages int + PriceInquiryHasPreviousPage bool + PriceInquiryHasNextPage bool + PriceInquiryPreviousURL string + PriceInquiryNextURL string + PriceInquiryPageSizeOptions []adminPageSizeOption +} + +type adminPageSizeOption struct { + Value int + Selected bool +} + +func newAdminUI(cfg config.Config, svc *service.Service) *adminUI { + tmpl := template.Must(template.ParseFS(adminTemplateFS, "templates/*.html")) + return &adminUI{cfg: cfg, svc: svc, templates: tmpl} +} + +func (a *adminUI) registerPublic(app *fiber.App) { + app.Get("/admin/login", a.loginPage) + app.Post("/admin/login", a.loginRedirect) + app.Get("/admin/oidc/start", a.oidcStart) + app.Get("/admin/oidc/callback", a.oidcCallback) + app.Post("/admin/logout", a.logout) +} + +func (a *adminUI) registerPages(admin fiber.Router) { + admin.Get("/", func(c fiber.Ctx) error { + return c.Redirect().To(defaultAdminPage) + }) + admin.Get("/price-inquiries", a.priceInquiriesPage) + admin.Post("/price-inquiries/settings", a.savePriceInquirySettings) +} + +func (a *adminUI) requireLogin(c fiber.Ctx) error { + if a.cfg.AdminAuthDisabled() { + c.Locals(adminUserLocalKey, adminUser{ + Subject: "auth:none", + DisplayName: "免登录管理员", + }) + return c.Next() + } + if user, ok := a.validSession(c.Cookies(adminSessionCookie)); ok { + c.Locals(adminUserLocalKey, user) + return c.Next() + } + if c.Method() == fiber.MethodGet && wantsHTML(c) { + next := url.QueryEscape(c.OriginalURL()) + return c.Redirect().To("/admin/login?next=" + next) + } + return fiber.NewError(fiber.StatusUnauthorized, "admin login required") +} + +func (a *adminUI) loginPage(c fiber.Ctx) error { + if a.cfg.AdminAuthDisabled() { + return c.Redirect().To(defaultAdminPage) + } + if _, ok := a.validSession(c.Cookies(adminSessionCookie)); ok { + return c.Redirect().To(safeAdminNext(c.Query("next"))) + } + next := safeAdminNext(c.Query("next")) + return a.render(c, "login.html", adminPageData{ + PageTitle: "后台登录", + Next: next, + Error: c.Query("error"), + OIDCLoginURL: "/admin/oidc/start?next=" + url.QueryEscape(next), + }) +} + +func (a *adminUI) loginRedirect(c fiber.Ctx) error { + if a.cfg.AdminAuthDisabled() { + return c.Redirect().To(defaultAdminPage) + } + next := safeAdminNext(c.FormValue("next")) + return c.Redirect().To("/admin/oidc/start?next=" + url.QueryEscape(next)) +} + +func (a *adminUI) oidcStart(c fiber.Ctx) error { + if !a.cfg.AdminOIDCLoginEnabled() { + return c.Redirect().To(defaultAdminPage) + } + oauthConfig, _, _, err := a.oidcClient(c.Context()) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + state := adminOIDCState{ + State: randomHex(24), + Nonce: randomHex(24), + Verifier: oauth2.GenerateVerifier(), + Next: safeAdminNext(c.Query("next")), + Expires: time.Now().Add(10 * time.Minute).Unix(), + } + expires := time.Unix(state.Expires, 0) + c.Cookie(&fiber.Cookie{ + Name: adminOIDCCookie, + Value: a.signCookieValue(state), + Path: "/admin", + MaxAge: int(time.Until(expires).Seconds()), + Expires: expires, + HTTPOnly: true, + SameSite: "Lax", + }) + return c.Redirect().To(oauthConfig.AuthCodeURL( + state.State, + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(state.Verifier), + oidc.Nonce(state.Nonce), + )) +} + +func (a *adminUI) oidcCallback(c fiber.Ctx) error { + if !a.cfg.AdminOIDCLoginEnabled() { + return c.Redirect().To(defaultAdminPage) + } + state, ok := a.validOIDCState(c.Cookies(adminOIDCCookie)) + a.clearOIDCStateCookie(c) + if !ok || !constantTimeEqual(state.State, c.Query("state")) { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权状态已失效,请重新登录")) + } + if errText := strings.TrimSpace(c.Query("error")); errText != "" { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape(errText)) + } + code := strings.TrimSpace(c.Query("code")) + if code == "" { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权回调缺少 code")) + } + oauthConfig, provider, verifier, err := a.oidcClient(c.Context()) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + token, err := oauthConfig.Exchange(c.Context(), code, oauth2.VerifierOption(state.Verifier)) + if err != nil { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权登录失败,请重试")) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权结果缺少 ID Token")) + } + idToken, err := verifier.Verify(c.Context(), rawIDToken) + if err != nil { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份校验失败")) + } + if !constantTimeEqual(idToken.Nonce, state.Nonce) { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权 nonce 校验失败")) + } + user, err := a.adminUserFromToken(c.Context(), provider, token, idToken) + if err != nil { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权用户信息读取失败")) + } + expires := time.Now().Add(8 * time.Hour) + if idToken.Expiry.Before(expires) { + expires = idToken.Expiry + } + if time.Until(expires) <= 0 { + return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份已过期,请重新登录")) + } + c.Cookie(&fiber.Cookie{ + Name: adminSessionCookie, + Value: a.signSession(user, expires), + Path: "/admin", + MaxAge: int(time.Until(expires).Seconds()), + Expires: expires, + HTTPOnly: true, + SameSite: "Lax", + }) + return c.Redirect().To(state.Next) +} + +func (a *adminUI) logout(c fiber.Ctx) error { + c.Cookie(&fiber.Cookie{ + Name: adminSessionCookie, + Value: "", + Path: "/admin", + MaxAge: -1, + Expires: time.Now().Add(-time.Hour), + HTTPOnly: true, + SameSite: "Lax", + }) + return c.Redirect().To("/admin/login") +} + +func (a *adminUI) priceInquiriesPage(c fiber.Ctx) error { + filter := service.AdminPriceInquiryFilter{ + Area: strings.TrimSpace(c.Query("area")), + Phone: strings.TrimSpace(c.Query("phone")), + Status: strings.TrimSpace(c.Query("status")), + TimeFrom: strings.TrimSpace(c.Query("time_from")), + TimeTo: strings.TrimSpace(c.Query("time_to")), + } + if !validAdminPriceInquiryStatusFilter(filter.Status) { + return fiber.NewError(fiber.StatusBadRequest, "咨询状态筛选无效") + } + page, pageSize, err := parseAdminPriceInquiryQueryPagination(c) + if err != nil { + return err + } + result, err := a.svc.AdminPriceInquiryRecordsPage(c.Context(), filter, page, pageSize) + if err != nil { + return err + } + return a.render(c, "price_inquiries.html", adminPageData{ + PageTitle: "价格咨询派单", + Active: "price-inquiries", + PriceInquiryRecords: result.Records, + AreaFilter: filter.Area, + PhoneFilter: filter.Phone, + StatusFilter: filter.Status, + TimeFromFilter: filter.TimeFrom, + TimeToFilter: filter.TimeTo, + HasPriceInquiryFilter: filter.Area != "" || filter.Phone != "" || filter.Status != "" || filter.TimeFrom != "" || filter.TimeTo != "", + PriceInquiryTotal: result.Total, + PriceInquiryPage: result.Page, + PriceInquiryPageSize: result.PageSize, + PriceInquiryTotalPages: result.TotalPages, + PriceInquiryHasPreviousPage: result.HasPreviousPage, + PriceInquiryHasNextPage: result.HasNextPage, + PriceInquiryPreviousURL: priceInquiriesURL(filter, result.PreviousPage, result.PageSize, "", ""), + PriceInquiryNextURL: priceInquiriesURL(filter, result.NextPage, result.PageSize, "", ""), + PriceInquiryPageSizeOptions: adminPageSizeOptions(result.PageSize), + Message: c.Query("message"), + Error: c.Query("error"), + }) +} + +func (a *adminUI) savePriceInquirySettings(c fiber.Ctx) error { + id := strings.TrimSpace(c.FormValue("id")) + status := strings.TrimSpace(c.FormValue("status")) + filter := service.AdminPriceInquiryFilter{ + Area: strings.TrimSpace(c.FormValue("area")), + Phone: strings.TrimSpace(c.FormValue("phone")), + Status: strings.TrimSpace(c.FormValue("status_filter")), + TimeFrom: strings.TrimSpace(c.FormValue("time_from")), + TimeTo: strings.TrimSpace(c.FormValue("time_to")), + } + page, pageSize, err := parseAdminPriceInquiryFormPagination(c) + if err != nil { + return err + } + if !validAdminPriceInquiryStatusFilter(filter.Status) { + return redirectPriceInquiries(c, service.AdminPriceInquiryFilter{}, page, pageSize, "error", "咨询状态筛选无效") + } + if id == "" { + return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询单不存在") + } + switch status { + case "pending", "assigned", "completed": + default: + return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询状态无效") + } + if _, err := a.svc.Q.UpdatePriceInquiry(c.Context(), db.UpdatePriceInquiryParams{ + ID: id, + Status: status, + Remark: strings.TrimSpace(c.FormValue("remark")), + }); err != nil { + return redirectPriceInquiries(c, filter, page, pageSize, "error", fmt.Sprintf("咨询设置保存失败:%v", err)) + } + return redirectPriceInquiries(c, filter, page, pageSize, "message", "咨询设置已保存") +} + +func validAdminPriceInquiryStatusFilter(status string) bool { + switch status { + case "", "pending", "assigned", "completed": + return true + default: + return false + } +} + +func parseAdminPriceInquiryQueryPagination(c fiber.Ctx) (int, int, error) { + return parseAdminPriceInquiryPagination(c.Query("page"), c.Query("page_size")) +} + +func parseAdminPriceInquiryFormPagination(c fiber.Ctx) (int, int, error) { + return parseAdminPriceInquiryPagination(c.FormValue("page"), c.FormValue("page_size")) +} + +func parseAdminPriceInquiryPagination(pageRaw, pageSizeRaw string) (int, int, error) { + page, err := positiveIntOrDefault(pageRaw, 1) + if err != nil { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "分页页码无效") + } + pageSize, err := positiveIntOrDefault(pageSizeRaw, defaultAdminPageSize) + if err != nil { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效") + } + if !validAdminPageSize(pageSize) { + return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效") + } + return page, pageSize, nil +} + +func positiveIntOrDefault(raw string, fallback int) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, fmt.Errorf("invalid positive integer %q", raw) + } + return value, nil +} + +func validAdminPageSize(pageSize int) bool { + switch pageSize { + case 20, 50, 100, 200: + return true + default: + return false + } +} + +func adminPageSizeOptions(selected int) []adminPageSizeOption { + options := []int{20, 50, 100, 200} + result := make([]adminPageSizeOption, 0, len(options)) + for _, option := range options { + result = append(result, adminPageSizeOption{ + Value: option, + Selected: option == selected, + }) + } + return result +} + +func (a *adminUI) render(c fiber.Ctx, name string, data adminPageData) error { + if user, ok := currentAdminUser(c); ok { + data.Username = user.DisplayName + } + data.PasswordChangeURL = a.passwordChangeURL() + var buf bytes.Buffer + if err := a.templates.ExecuteTemplate(&buf, name, data); err != nil { + return err + } + c.Type("html", "utf-8") + return c.Send(buf.Bytes()) +} + +type adminUser struct { + Subject string `json:"sub"` + DisplayName string `json:"name"` +} + +type adminSession struct { + Subject string `json:"sub"` + DisplayName string `json:"name"` + Expires int64 `json:"exp"` +} + +type adminOIDCState struct { + State string `json:"state"` + Nonce string `json:"nonce"` + Verifier string `json:"verifier"` + Next string `json:"next"` + Expires int64 `json:"exp"` +} + +type adminOIDCClaims struct { + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` +} + +func (a *adminUI) signSession(user adminUser, expires time.Time) string { + return a.signCookieValue(adminSession{ + Subject: user.Subject, + DisplayName: user.DisplayName, + Expires: expires.Unix(), + }) +} + +func (a *adminUI) validSession(token string) (adminUser, bool) { + var session adminSession + if !a.verifyCookieValue(token, &session) { + return adminUser{}, false + } + if strings.TrimSpace(session.Subject) == "" || time.Now().Unix() >= session.Expires { + return adminUser{}, false + } + displayName := strings.TrimSpace(session.DisplayName) + if displayName == "" { + displayName = session.Subject + } + return adminUser{Subject: session.Subject, DisplayName: displayName}, true +} + +func (a *adminUI) validOIDCState(token string) (adminOIDCState, bool) { + var state adminOIDCState + if !a.verifyCookieValue(token, &state) { + return adminOIDCState{}, false + } + if strings.TrimSpace(state.State) == "" || strings.TrimSpace(state.Nonce) == "" || strings.TrimSpace(state.Verifier) == "" { + return adminOIDCState{}, false + } + if time.Now().Unix() >= state.Expires { + return adminOIDCState{}, false + } + state.Next = safeAdminNext(state.Next) + return state, true +} + +func (a *adminUI) signCookieValue(value any) string { + payloadBytes, err := json.Marshal(value) + if err != nil { + return "" + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + sig := a.sessionMAC(payload) + return payload + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +func (a *adminUI) verifyCookieValue(token string, value any) bool { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + if !hmac.Equal(signature, a.sessionMAC(parts[0])) { + return false + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + return json.Unmarshal(payloadBytes, value) == nil +} + +func (a *adminUI) sessionMAC(payload string) []byte { + secret := a.cfg.AdminSessionSecret + if secret == "" { + secret = a.cfg.OIDCClientSecret + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + return mac.Sum(nil) +} + +func (a *adminUI) oidcClient(ctx context.Context) (*oauth2.Config, *oidc.Provider, *oidc.IDTokenVerifier, error) { + a.oidcMu.Lock() + defer a.oidcMu.Unlock() + if a.oauth2Config != nil && a.oidcProvider != nil && a.oidcVerifier != nil { + return a.oauth2Config, a.oidcProvider, a.oidcVerifier, nil + } + provider, err := oidc.NewProvider(ctx, a.cfg.OIDCIssuer) + if err != nil { + return nil, nil, nil, fmt.Errorf("初始化 OIDC Provider 失败:%w", err) + } + oauthConfig := &oauth2.Config{ + ClientID: a.cfg.OIDCClientID, + ClientSecret: a.cfg.OIDCClientSecret, + RedirectURL: a.cfg.OIDCRedirectURI, + Endpoint: provider.Endpoint(), + Scopes: oidcScopes(a.cfg.OIDCScopes), + } + verifier := provider.Verifier(&oidc.Config{ClientID: a.cfg.OIDCClientID}) + a.oidcProvider = provider + a.oauth2Config = oauthConfig + a.oidcVerifier = verifier + return oauthConfig, provider, verifier, nil +} + +func (a *adminUI) adminUserFromToken(ctx context.Context, provider *oidc.Provider, token *oauth2.Token, idToken *oidc.IDToken) (adminUser, error) { + claims := adminOIDCClaims{} + if err := idToken.Claims(&claims); err != nil { + return adminUser{}, err + } + userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err == nil { + _ = userInfo.Claims(&claims) + } + subject := strings.TrimSpace(idToken.Subject) + displayName := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Email, claims.PhoneNumber, subject) + if displayName == "" { + displayName = "管理员" + } + return adminUser{Subject: subject, DisplayName: displayName}, nil +} + +func currentAdminUser(c fiber.Ctx) (adminUser, bool) { + value := c.Locals(adminUserLocalKey) + user, ok := value.(adminUser) + if !ok || strings.TrimSpace(user.DisplayName) == "" { + return adminUser{}, false + } + return user, true +} + +func (a *adminUI) clearOIDCStateCookie(c fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: adminOIDCCookie, + Value: "", + Path: "/admin", + MaxAge: -1, + Expires: time.Now().Add(-time.Hour), + HTTPOnly: true, + SameSite: "Lax", + }) +} + +func oidcScopes(value string) []string { + seen := map[string]bool{"openid": true} + scopes := []string{"openid"} + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + for _, part := range parts { + scope := strings.TrimSpace(part) + if scope != "" && !seen[scope] { + scopes = append(scopes, scope) + seen[scope] = true + } + } + return scopes +} + +func constantTimeEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func wantsHTML(c fiber.Ctx) bool { + accept := c.Get(fiber.HeaderAccept) + return accept == "" || strings.Contains(accept, "text/html") +} + +func safeAdminNext(next string) string { + next = strings.TrimSpace(next) + if strings.HasPrefix(next, "/admin") && !strings.HasPrefix(next, "/admin/login") { + return next + } + return defaultAdminPage +} + +func (a *adminUI) passwordChangeURL() string { + if !a.cfg.AdminOIDCLoginEnabled() { + return "" + } + issuer := strings.TrimSpace(a.cfg.OIDCIssuer) + redirectURL := strings.TrimSpace(a.cfg.AdminPasswordChangeRedirectURL) + if issuer == "" || redirectURL == "" { + return "" + } + u, err := url.Parse(issuer) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + u.RawQuery = "" + u.Fragment = "" + issuerPath := strings.TrimRight(u.Path, "/") + if strings.HasSuffix(issuerPath, "/oidc") { + issuerPath = strings.TrimSuffix(issuerPath, "/oidc") + } + u.Path = strings.TrimRight(issuerPath, "/") + "/account/password" + q := u.Query() + q.Set("redirect", redirectURL) + q.Set("show_success", "true") + u.RawQuery = q.Encode() + return u.String() +} + +func redirectPriceInquiries(c fiber.Ctx, filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) error { + return c.Redirect().To(priceInquiriesURL(filter, page, pageSize, key, message)) +} + +func priceInquiriesURL(filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) string { + query := url.Values{} + if key != "" { + query.Set(key, message) + } + if filter.Area != "" { + query.Set("area", filter.Area) + } + if filter.Phone != "" { + query.Set("phone", filter.Phone) + } + if filter.Status != "" { + query.Set("status", filter.Status) + } + if filter.TimeFrom != "" { + query.Set("time_from", filter.TimeFrom) + } + if filter.TimeTo != "" { + query.Set("time_to", filter.TimeTo) + } + if page > 1 { + query.Set("page", strconv.Itoa(page)) + } + if pageSize != defaultAdminPageSize { + query.Set("page_size", strconv.Itoa(pageSize)) + } + encoded := query.Encode() + if encoded == "" { + return "/admin/price-inquiries" + } + return "/admin/price-inquiries?" + encoded +} +func randomHex(size int) string { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(buf) +} diff --git a/backend/internal/httpapi/admin_test.go b/backend/internal/httpapi/admin_test.go new file mode 100644 index 0000000..b5f1e98 --- /dev/null +++ b/backend/internal/httpapi/admin_test.go @@ -0,0 +1,775 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service" + "github.com/sirupsen/logrus" + _ "modernc.org/sqlite" +) + +func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) { + app, closeDB := newTestAdminAppWithConfig(t, config.Config{ + AdminAuthMode: config.AdminAuthModeOIDC, + AdminSessionSecret: "test-session-secret", + OIDCIssuer: "https://auth.example.com/oidc", + OIDCClientID: "test-client-id", + OIDCClientSecret: "test-client-secret", + OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback", + }) + defer closeDB() + + resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusSeeOther) + } + if location := resp.Header.Get("Location"); !strings.HasPrefix(location, "/admin/login?next=") { + t.Fatalf("Location = %q, want login redirect", location) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/projects", "", map[string]string{"Accept": "application/json"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } + + cookie := adminSessionCookieHeader(config.Config{ + AdminSessionSecret: "test-session-secret", + }, adminUser{Subject: "logto-user-1", DisplayName: "王医生"}) + resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("admin root status = %d, want %d", resp.StatusCode, http.StatusSeeOther) + } + if location := resp.Header.Get("Location"); location != defaultAdminPage { + t.Fatalf("admin root Location = %q, want %q", location, defaultAdminPage) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if !strings.Contains(body, "价格咨询派单") || !strings.Contains(body, "创建时间") || !strings.Contains(body, "最近提交时间") || !strings.Contains(body, "备注") || !strings.Contains(body, "设置") { + t.Fatalf("price inquiries page did not render expected columns: %s", body) + } + for _, expected := range []string{ + "共 0 条咨询,第 1 / 1 页", + ``, + ``, + `name="time_from" value="2000-01-01" type="date"`, + `name="time_to" value="2999-12-31" type="date"`, + `class="status-pill status-pending">待派单`, + `class="button secondary small">设置`, + `href="/admin/price-inquiries">全部`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("admin price inquiry page missing %q: %s", expected, body) + } + } + if strings.Contains(body, "

价格咨询派单

") || strings.Contains(body, "咨询单 ID") { + t.Fatalf("admin price inquiry page rendered removed title or ID column: %s", body) + } + for _, removed := range []string{`name="created_from"`, `name="created_to"`, `name="updated_from"`, `name="updated_to"`} { + if strings.Contains(body, removed) { + t.Fatalf("admin price inquiry page rendered removed time filter %q: %s", removed, body) + } + } + if strings.Count(body, "13900005555") != 1 { + t.Fatalf("admin price inquiry page rendered duplicate rows: %s", body) + } + + settings := url.Values{ + "id": {createdInquiry.ID}, + "area": {"浦东"}, + "phone": {"13900005555"}, + "status_filter": {"pending"}, + "time_from": {"2000-01-01"}, + "time_to": {"2999-12-31"}, + "status": {"assigned"}, + "remark": {"已派给浦东门诊"}, + } + resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{ + "Cookie": cookie, + "Content-Type": "application/x-www-form-urlencoded", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("save price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp)) + } + if location := resp.Header.Get("Location"); !strings.Contains(location, "area=%E6%B5%A6%E4%B8%9C") || !strings.Contains(location, "phone=13900005555") || !strings.Contains(location, "status=pending") || !strings.Contains(location, "time_from=2000-01-01") { + t.Fatalf("save price inquiry settings Location = %q, want preserved filters", location) + } + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&status=assigned&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK || !strings.Contains(body, `class="status-pill status-assigned">已经派单`) || !strings.Contains(body, "已派给浦东门诊") || !strings.Contains(body, ``) { + t.Fatalf("saved price inquiry settings not rendered, status/body = %d/%s", resp.StatusCode, body) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?status=completed", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin status-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") { + t.Fatalf("admin status filter did not remove non-matching inquiries: %s", body) + } + + settings.Set("status", "completed") + settings.Set("remark", "用户已联系,等待到院") + resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{ + "Cookie": cookie, + "Content-Type": "application/x-www-form-urlencoded", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("overwrite price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp)) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("updated admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + for _, expected := range []string{"已跟进", "用户已联系,等待到院"} { + if !strings.Contains(body, expected) { + t.Fatalf("updated admin price inquiry page missing %q: %s", expected, body) + } + } + if strings.Contains(body, "已派给浦东门诊") { + t.Fatalf("old price inquiry remark was not overwritten: %s", body) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?phone=13999999999", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin phone-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") { + t.Fatalf("admin phone filter did not remove non-matching inquiries: %s", body) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?time_from=2999-01-01", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin time-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") { + t.Fatalf("admin time filters did not remove out-of-range inquiries: %s", body) + } +} + +func TestAdminPriceInquiryPagination(t *testing.T) { + app, closeDB := newTestAdminApp(t) + defer closeDB() + + var firstInquiryID string + for i := 0; i < 21; i++ { + phone := fmt.Sprintf("139100100%02d", i) + id := createTestPriceInquiry(t, app, fmt.Sprintf("price_inquiry_page_%02d", i), phone) + if i == 0 { + firstInquiryID = id + } + } + + cookie := loginAdmin(t, app) + resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page_size=20", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("first page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + for _, expected := range []string{ + "共 21 条咨询,第 1 / 2 页", + ``, + ``, + ``, + `href="/admin/price-inquiries?page=2&page_size=20">下一页`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("first page missing pagination element %q: %s", expected, body) + } + } + if strings.Count(body, `class="button secondary small">设置`) != 20 { + t.Fatalf("first page rendered wrong row count: %s", body) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page=2&page_size=20", "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("second page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + for _, expected := range []string{ + "共 21 条咨询,第 2 / 2 页", + `href="/admin/price-inquiries?page_size=20">上一页`, + `下一页`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("second page missing pagination element %q: %s", expected, body) + } + } + if strings.Count(body, `class="button secondary small">设置`) != 1 { + t.Fatalf("second page rendered wrong row count: %s", body) + } + + settings := url.Values{ + "id": {firstInquiryID}, + "status": {"assigned"}, + "remark": {"分页保留备注"}, + "page": {"2"}, + "page_size": {"20"}, + } + resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{ + "Cookie": cookie, + "Content-Type": "application/x-www-form-urlencoded", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("save paged settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp)) + } + location := resp.Header.Get("Location") + if !strings.Contains(location, "page=2") || !strings.Contains(location, "page_size=20") || !strings.Contains(location, "message=") { + t.Fatalf("save paged settings Location = %q, want preserved pagination", location) + } +} + +func TestAdminPriceInquiryRejectsInvalidPagination(t *testing.T) { + app, closeDB := newTestAdminApp(t) + defer closeDB() + + cookie := loginAdmin(t, app) + for _, target := range []string{ + "/admin/price-inquiries?page=0", + "/admin/price-inquiries?page=abc", + "/admin/price-inquiries?page_size=10", + "/admin/price-inquiries?page_size=abc", + } { + resp := doRequest(t, app, http.MethodGet, target, "", map[string]string{ + "Cookie": cookie, + "Accept": "text/html", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("%s status = %d, want %d: %s", target, resp.StatusCode, http.StatusBadRequest, readBody(t, resp)) + } + } +} + +func TestWechatPhoneCodeDoesNotBindMockPhone(t *testing.T) { + app, closeDB := newTestAdminApp(t) + defer closeDB() + + resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_code_only"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp)) + } + var session struct { + User struct { + Openid string `json:"openid"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + t.Fatalf("decode session: %v", err) + } + + resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"test-phone-code"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + body := readBody(t, resp) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("phone code bind status = %d, want %d: %s", resp.StatusCode, http.StatusInternalServerError, body) + } + if !strings.Contains(body, "GUAHAO_WECHAT_APPID") || !strings.Contains(body, "GUAHAO_WECHAT_SECRET") { + t.Fatalf("phone code bind error = %s, want missing WeChat configuration error", body) + } + + resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900002222"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + body = readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("explicit phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if strings.Contains(body, "13800005678") || !strings.Contains(body, "13900002222") { + t.Fatalf("explicit phone bind body = %s, want real provided phone only", body) + } +} + +func TestWechatPhoneBindCreatesMissingDemoUser(t *testing.T) { + app, closeDB := newTestAdminApp(t) + defer closeDB() + + resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"demo_openid_demo","phone":"13900004444"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if !strings.Contains(body, `"openid":"demo_openid_demo"`) || !strings.Contains(body, "13900004444") { + t.Fatalf("phone bind body = %s, want created demo user with bound phone", body) + } +} + +func TestWechatPhoneCodeExchangesAndBindsRealPhone(t *testing.T) { + var tokenCalls atomic.Int64 + var phoneCalls atomic.Int64 + wechat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/cgi-bin/token": + tokenCalls.Add(1) + if r.URL.Query().Get("grant_type") != "client_credential" { + t.Fatalf("grant_type = %q, want client_credential", r.URL.Query().Get("grant_type")) + } + if r.URL.Query().Get("appid") != "test-appid" || r.URL.Query().Get("secret") != "test-secret" { + t.Fatalf("unexpected appid/secret query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":7200}`)) + case "/wxa/business/getuserphonenumber": + phoneCalls.Add(1) + if r.URL.Query().Get("access_token") != "test-token" { + t.Fatalf("access_token = %q, want test-token", r.URL.Query().Get("access_token")) + } + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode phone request body: %v", err) + } + if body.Code != "real-phone-code" { + t.Fatalf("phone code = %q, want real-phone-code", body.Code) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","phone_info":{"phoneNumber":"13900003333","purePhoneNumber":"13900003333","countryCode":"86"}}`)) + default: + http.NotFound(w, r) + } + })) + defer wechat.Close() + + app, closeDB := newTestAdminAppWithConfig(t, config.Config{ + WeChatAppID: "test-appid", + WeChatAppSecret: "test-secret", + WeChatAPIBase: wechat.URL, + }) + defer closeDB() + + resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_exchange"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp)) + } + var session struct { + User struct { + Openid string `json:"openid"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + t.Fatalf("decode session: %v", err) + } + + resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"real-phone-code"}`, map[string]string{ + "Content-Type": "application/json", + }) + defer resp.Body.Close() + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + if !strings.Contains(body, "13900003333") || strings.Contains(body, "13800005678") { + t.Fatalf("phone bind body = %s, want exchanged real phone only", body) + } + if tokenCalls.Load() != 1 || phoneCalls.Load() != 1 { + t.Fatalf("tokenCalls/phoneCalls = %d/%d, want 1/1", tokenCalls.Load(), phoneCalls.Load()) + } +} + +func newTestAdminApp(t *testing.T) (*fiber.App, func()) { + return newTestAdminAppWithConfig(t, config.Config{}) +} + +func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber.App, func()) { + t.Helper() + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + ctx := context.Background() + schemaPath := filepath.Join("..", "..", "migrations", "schema.sql") + if err := service.ApplySchema(ctx, database, schemaPath); err != nil { + database.Close() + t.Fatalf("apply schema: %v", err) + } + log := logrus.New() + log.SetOutput(io.Discard) + svc := service.New(database, log) + if err := svc.Seed(ctx); err != nil { + database.Close() + t.Fatalf("seed: %v", err) + } + session, err := svc.Login(ctx, "admin_test") + if err != nil { + database.Close() + t.Fatalf("login seeded user: %v", err) + } + if _, err := svc.BindPhone(ctx, session.User.Openid, "13900001111"); err != nil { + database.Close() + t.Fatalf("bind seeded phone: %v", err) + } + _, err = svc.CreateBooking(ctx, service.CreateBookingRequest{ + OpenID: session.User.Openid, + ProjectID: "project_fresh_clean", + Date: time.Now().AddDate(0, 0, 1).Format("2006-01-02"), + StartTime: "09:30", + Phone: "13900001111", + }) + if err != nil { + database.Close() + t.Fatalf("create seeded booking: %v", err) + } + cfg := config.Config{ + AllowOrigins: "*", + UploadDir: t.TempDir(), + WeChatAppID: cfgOverride.WeChatAppID, + WeChatAppSecret: cfgOverride.WeChatAppSecret, + WeChatAPIBase: cfgOverride.WeChatAPIBase, + AdminAuthMode: config.AdminAuthModeNone, + AdminSessionSecret: "test-session-secret", + AdminPasswordChangeRedirectURL: cfgOverride.AdminPasswordChangeRedirectURL, + OIDCIssuer: cfgOverride.OIDCIssuer, + OIDCClientID: cfgOverride.OIDCClientID, + OIDCClientSecret: cfgOverride.OIDCClientSecret, + OIDCRedirectURI: cfgOverride.OIDCRedirectURI, + OIDCScopes: cfgOverride.OIDCScopes, + } + if cfgOverride.AdminAuthMode != "" { + cfg.AdminAuthMode = cfgOverride.AdminAuthMode + } + if cfgOverride.AdminSessionSecret != "" { + cfg.AdminSessionSecret = cfgOverride.AdminSessionSecret + } + if cfg.AdminPasswordChangeRedirectURL == "" { + cfg.AdminPasswordChangeRedirectURL = "https://gh.yqbmb.com/admin/price-inquiries" + } + return New(cfg, svc, log), func() { database.Close() } +} + +func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) string { + t.Helper() + + resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", fmt.Sprintf(`{"code":%q}`, code), map[string]string{ + "Content-Type": "application/json", + }) + var session struct { + User struct { + Openid string `json:"openid"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + resp.Body.Close() + t.Fatalf("decode session: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("session status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", fmt.Sprintf(`{"openid":%q,"phone":%q}`, session.User.Openid, phone), map[string]string{ + "Content-Type": "application/json", + }) + body := readBody(t, resp) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) + } + + resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", fmt.Sprintf(`{"openid":%q,"province":"上海市","city":"上海市","district":"浦东新区","project_name":"牙齿种植"}`, session.User.Openid), map[string]string{ + "Content-Type": "application/json", + }) + body = readBody(t, resp) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body) + } + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(body), &created); err != nil { + t.Fatalf("decode price inquiry: %v", err) + } + if created.ID == "" { + t.Fatalf("price inquiry missing id: %s", body) + } + return created.ID +} + +func loginAdmin(t *testing.T, app *fiber.App) string { + t.Helper() + return "" +} + +func adminSessionCookieHeader(cfg config.Config, user adminUser) string { + ui := newAdminUI(cfg, nil) + token := ui.signSession(user, time.Now().Add(8*time.Hour)) + return adminSessionCookie + "=" + token +} + +func doRequest(t *testing.T, app *fiber.App, method, target, body string, headers map[string]string) *http.Response { + t.Helper() + req := httptest.NewRequest(method, target, strings.NewReader(body)) + return testRequest(t, app, req, headers) +} + +func testRequest(t *testing.T, app *fiber.App, req *http.Request, headers map[string]string) *http.Response { + t.Helper() + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := app.Test(req) + if err != nil { + t.Fatalf("%s %s: %v", req.Method, req.URL.String(), err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(body) +} diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go new file mode 100644 index 0000000..e61e6db --- /dev/null +++ b/backend/internal/httpapi/httpapi.go @@ -0,0 +1,514 @@ +package httpapi + +import ( + "bytes" + "database/sql" + "encoding/csv" + "errors" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/compress" + "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service" + "github.com/sirupsen/logrus" +) + +func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App { + wechatPhone := newWeChatPhoneClient(cfg) + app := fiber.New(fiber.Config{ + AppName: "Smile First API", + BodyLimit: 8 * 1024 * 1024, + ErrorHandler: errorHandler, + }) + app.Use(recover.New()) + app.Use(compress.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: splitList(cfg.AllowOrigins), + AllowHeaders: []string{ + fiber.HeaderOrigin, + fiber.HeaderContentType, + fiber.HeaderAccept, + fiber.HeaderAuthorization, + }, + AllowMethods: []string{ + fiber.MethodGet, + fiber.MethodPost, + fiber.MethodPatch, + fiber.MethodDelete, + fiber.MethodOptions, + }, + })) + app.Use(logRequests(log)) + + app.Get("/uploads/*", serveUploadedFile(cfg)) + + app.Get("/healthz", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{"ok": true, "time": time.Now().Format(time.RFC3339)}) + }) + + api := app.Group("/api") + api.Get("/app/bootstrap", func(c fiber.Ctx) error { + heroImage, err := svc.HomeHeroImage(c.Context()) + if err != nil { + return err + } + categories, err := svc.CategoriesWithProjects(c.Context(), false) + if err != nil { + return err + } + recommended := make([]service.ProjectView, 0, 4) + for _, category := range categories { + for _, project := range category.Projects { + if len(recommended) < 4 { + recommended = append(recommended, service.ToProjectView(project)) + } + } + } + return c.JSON(fiber.Map{ + "brand": fiber.Map{ + "name": "Smile First", + "title": "选好项目,约好护理时间", + "hero": "自然之力,予你自信笑容", + "subtitle": "温和护理 · 精致体验 · 用心陪伴", + "hero_image": heroImage, + }, + "hero_image": heroImage, + "features": []string{"自然之力,温和呵护", "精致体验,贴心服务", "预约提醒,省时省心"}, + "recommended_projects": recommended, + }) + }) + api.Get("/projects", func(c fiber.Ctx) error { + categories, err := svc.CategoriesWithProjects(c.Context(), false) + if err != nil { + return err + } + return c.JSON(fiber.Map{"categories": service.CategoryViews(categories)}) + }) + api.Post("/auth/wechat/session", func(c fiber.Ctx) error { + var req struct { + Code string `json:"code"` + } + if err := bindBody(c, &req); err != nil { + return err + } + session, err := svc.Login(c.Context(), req.Code) + if err != nil { + return err + } + return c.JSON(session) + }) + api.Post("/auth/wechat/phone", func(c fiber.Ctx) error { + var req struct { + OpenID string `json:"openid"` + Phone string `json:"phone"` + PhoneCode string `json:"phoneCode"` + } + if err := bindBody(c, &req); err != nil { + return err + } + phone := strings.TrimSpace(req.Phone) + phoneCode := strings.TrimSpace(req.PhoneCode) + log.WithFields(logrus.Fields{ + "openid": maskIdentifierForLog(req.OpenID), + "has_openid": strings.TrimSpace(req.OpenID) != "", + "has_phone": phone != "", + "phone": maskPhoneForLog(phone), + "has_phone_code": phoneCode != "", + "phone_code_len": len(phoneCode), + "ip": c.IP(), + }).Info("wechat phone bind request") + if phone == "" { + if phoneCode != "" { + exchangedPhone, err := wechatPhone.PhoneNumber(c.Context(), phoneCode) + if err != nil { + log.WithFields(logrus.Fields{ + "openid": maskIdentifierForLog(req.OpenID), + "phone_code_len": len(phoneCode), + }).WithError(err).Error("wechat phone exchange failed") + var configErr missingWeChatConfigError + if errors.As(err, &configErr) { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return fiber.NewError(fiber.StatusBadGateway, err.Error()) + } + phone = exchangedPhone + log.WithFields(logrus.Fields{ + "openid": maskIdentifierForLog(req.OpenID), + "phone_code_len": len(phoneCode), + "phone": maskPhoneForLog(phone), + }).Info("wechat phone exchange success") + } + } + if phone == "" { + log.WithField("openid", maskIdentifierForLog(req.OpenID)).Warn("wechat phone bind rejected: phone missing") + return badRequest("phone is required") + } + user, err := svc.BindPhone(c.Context(), req.OpenID, phone) + if err != nil { + log.WithFields(logrus.Fields{ + "openid": maskIdentifierForLog(req.OpenID), + "phone": maskPhoneForLog(phone), + }).WithError(err).Error("wechat phone bind failed") + return err + } + log.WithFields(logrus.Fields{ + "openid": maskIdentifierForLog(user.Openid), + "user_id": user.ID, + "phone": maskPhoneForLog(phone), + }).Info("wechat phone bind success") + return c.JSON(fiber.Map{"user": user}) + }) + api.Post("/bookings", func(c fiber.Ctx) error { + var req service.CreateBookingRequest + if err := bindBody(c, &req); err != nil { + return err + } + detail, err := svc.CreateBooking(c.Context(), req) + if err != nil { + return err + } + return c.Status(fiber.StatusCreated).JSON(service.BookingDetailToView(detail)) + }) + api.Post("/price-inquiries", func(c fiber.Ctx) error { + var req service.CreatePriceInquiryRequest + if err := bindBody(c, &req); err != nil { + return err + } + inquiry, err := svc.CreatePriceInquiry(c.Context(), req) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return badRequest("phone authorization is required") + } + if strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "unsupported") { + return badRequest(err.Error()) + } + return err + } + log.WithFields(logrus.Fields{ + "inquiry_id": inquiry.ID, + "openid": maskIdentifierForLog(req.OpenID), + "phone": maskPhoneForLog(inquiry.Phone), + "province": inquiry.Province, + "city": inquiry.City, + "district": inquiry.District, + "project_name": inquiry.ProjectName, + "status": inquiry.Status, + "created_at": inquiry.CreatedAt, + "updated_at": inquiry.UpdatedAt, + }).Info("price inquiry submitted") + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "id": inquiry.ID, + "status": inquiry.Status, + "created_at": inquiry.CreatedAt, + "updated_at": inquiry.UpdatedAt, + }) + }) + api.Get("/bookings", func(c fiber.Ctx) error { + phone := strings.TrimSpace(c.Query("phone")) + if phone == "" { + return badRequest("phone is required") + } + details, err := svc.ListBookingDetailsByPhone(c.Context(), phone) + if err != nil { + return err + } + return c.JSON(fiber.Map{"bookings": service.BookingDetailsToViews(details)}) + }) + api.Get("/bookings/:id", func(c fiber.Ctx) error { + booking, err := svc.Q.GetBooking(c.Context(), c.Params("id")) + if err != nil { + return err + } + detail, err := svc.BookingDetail(c.Context(), booking) + if err != nil { + return err + } + return c.JSON(service.BookingDetailToView(detail)) + }) + api.Patch("/bookings/:id/cancel", func(c fiber.Ctx) error { + var req struct { + Phone string `json:"phone"` + } + if err := bindBody(c, &req); err != nil { + return err + } + detail, err := svc.CancelBooking(c.Context(), c.Params("id"), req.Phone) + if err != nil { + return err + } + return c.JSON(service.BookingDetailToView(detail)) + }) + + adminUI := newAdminUI(cfg, svc) + adminUI.registerPublic(app) + admin := app.Group("/admin", adminUI.requireLogin) + adminUI.registerPages(admin) + admin.Get("/categories", func(c fiber.Ctx) error { + categories, err := svc.CategoriesWithProjects(c.Context(), true) + if err != nil { + return err + } + return c.JSON(fiber.Map{"categories": categories}) + }) + admin.Post("/categories", func(c fiber.Ctx) error { + var req db.CreateCategoryParams + if err := bindBody(c, &req); err != nil { + return err + } + if req.ID == "" { + req.ID = "cat_" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + if req.Status == "" { + req.Status = "active" + } + category, err := svc.Q.CreateCategory(c.Context(), req) + if err != nil { + return err + } + return c.Status(fiber.StatusCreated).JSON(category) + }) + admin.Get("/projects", func(c fiber.Ctx) error { + categories, err := svc.CategoriesWithProjects(c.Context(), true) + if err != nil { + return err + } + return c.JSON(fiber.Map{"categories": categories}) + }) + admin.Post("/projects", func(c fiber.Ctx) error { + var req db.CreateProjectParams + if err := bindBody(c, &req); err != nil { + return err + } + if req.ID == "" { + req.ID = "project_" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + if req.Status == "" { + req.Status = "active" + } + project, err := svc.Q.CreateProject(c.Context(), req) + if err != nil { + return err + } + return c.Status(fiber.StatusCreated).JSON(project) + }) + admin.Get("/schedules", func(c fiber.Ctx) error { + projectID := firstNonEmpty(c.Query("projectId"), c.Query("project_id")) + days, err := svc.ScheduleDays(c.Context(), projectID, c.Query("month")) + if err != nil { + return err + } + return c.JSON(fiber.Map{"days": days}) + }) + admin.Post("/schedules/days", func(c fiber.Ctx) error { + var req db.CreateScheduleDayParams + if err := bindBody(c, &req); err != nil { + return err + } + if req.ID == "" { + req.ID = "day_" + req.ProjectID + "_" + strings.ReplaceAll(req.Date, "-", "") + } + if req.Status == "" { + req.Status = "available" + } + day, err := svc.Q.CreateScheduleDay(c.Context(), req) + if err != nil { + return err + } + return c.Status(fiber.StatusCreated).JSON(day) + }) + admin.Patch("/schedules/days/:id", func(c fiber.Ctx) error { + var req struct { + Status string `json:"status"` + Note string `json:"note"` + } + if err := bindBody(c, &req); err != nil { + return err + } + day, err := svc.Q.UpdateScheduleDay(c.Context(), db.UpdateScheduleDayParams{ + ID: c.Params("id"), Status: req.Status, Note: req.Note, + }) + if err != nil { + return err + } + return c.JSON(day) + }) + admin.Post("/schedules/slots", func(c fiber.Ctx) error { + var req service.CreateTimeSlotRequest + if err := bindBody(c, &req); err != nil { + return err + } + slot, err := svc.CreateTimeSlot(c.Context(), req) + if err != nil { + return err + } + return c.Status(fiber.StatusCreated).JSON(slot) + }) + admin.Patch("/schedules/slots/:id", func(c fiber.Ctx) error { + var req service.UpdateTimeSlotRequest + if err := bindBody(c, &req); err != nil { + return err + } + slot, err := svc.UpdateTimeSlot(c.Context(), c.Params("id"), req) + if err != nil { + return err + } + return c.JSON(slot) + }) + admin.Get("/bookings", func(c fiber.Ctx) error { + details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), queryInt(c, "limit", 50), queryInt(c, "offset", 0)) + if err != nil { + return err + } + return c.JSON(fiber.Map{"bookings": details}) + }) + admin.Patch("/bookings/:id/status", func(c fiber.Ctx) error { + var req struct { + Status string `json:"status"` + } + if err := bindBody(c, &req); err != nil { + return err + } + booking, err := svc.Q.UpdateBookingStatus(c.Context(), db.UpdateBookingStatusParams{ID: c.Params("id"), Status: req.Status}) + if err != nil { + return err + } + detail, err := svc.BookingDetail(c.Context(), booking) + if err != nil { + return err + } + return c.JSON(detail) + }) + admin.Get("/reports/bookings", func(c fiber.Ctx) error { + details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), 500, 0) + if err != nil { + return err + } + if c.Query("format") != "csv" { + return c.JSON(fiber.Map{"bookings": details}) + } + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write([]string{"booking_id", "status", "phone", "project", "date", "time", "created_at"}) + for _, detail := range details { + _ = w.Write([]string{ + detail.Booking.ID, detail.Booking.Status, detail.Booking.Phone, + detail.Project.Name, detail.Day.Date, detail.Slot.StartTime, + detail.Booking.CreatedAt, + }) + } + w.Flush() + c.Set(fiber.HeaderContentType, "text/csv; charset=utf-8") + c.Set(fiber.HeaderContentDisposition, `attachment; filename="bookings.csv"`) + return c.Send(buf.Bytes()) + }) + + return app +} + +func serveUploadedFile(cfg config.Config) fiber.Handler { + return func(c fiber.Ctx) error { + cleanPath := path.Clean("/" + c.Params("*")) + if cleanPath == "/" { + return fiber.ErrNotFound + } + fullPath := filepath.Join(cfg.UploadDir, filepath.FromSlash(strings.TrimPrefix(cleanPath, "/"))) + base, err := filepath.Abs(cfg.UploadDir) + if err != nil { + return err + } + full, err := filepath.Abs(fullPath) + if err != nil { + return err + } + if full != base && !strings.HasPrefix(full, base+string(filepath.Separator)) { + return fiber.ErrForbidden + } + c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable") + return c.SendFile(full) + } +} + +func errorHandler(c fiber.Ctx, err error) error { + var e *fiber.Error + if errors.As(err, &e) { + return c.Status(e.Code).JSON(fiber.Map{"error": e.Message}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) +} + +func badRequest(msg string) error { + return fiber.NewError(fiber.StatusBadRequest, msg) +} + +func bindBody(c fiber.Ctx, out any) error { + if err := c.Bind().Body(out); err != nil { + return badRequest(err.Error()) + } + return nil +} + +func logRequests(log *logrus.Logger) fiber.Handler { + return func(c fiber.Ctx) error { + start := time.Now() + err := c.Next() + log.WithFields(logrus.Fields{ + "method": c.Method(), + "path": c.Path(), + "status": c.Response().StatusCode(), + "latency_ms": time.Since(start).Milliseconds(), + }).Info("request") + return err + } +} + +func maskPhoneForLog(phone string) string { + phone = strings.TrimSpace(phone) + if len(phone) < 7 { + return phone + } + return phone[:3] + "****" + phone[len(phone)-4:] +} + +func maskIdentifierForLog(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 8 { + return value + } + return value[:4] + "..." + value[len(value)-4:] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func splitList(value string) []string { + parts := strings.Split(value, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + +func queryInt(c fiber.Ctx, key string, fallback int64) int64 { + value, err := strconv.ParseInt(c.Query(key), 10, 64) + if err != nil { + return fallback + } + return value +} diff --git a/backend/internal/httpapi/templates/base.html b/backend/internal/httpapi/templates/base.html new file mode 100644 index 0000000..758aaa1 --- /dev/null +++ b/backend/internal/httpapi/templates/base.html @@ -0,0 +1,393 @@ +{{define "admin_head"}} + + + + + + {{.PageTitle}} - 管理后台 + + +{{end}} + +{{define "admin_nav"}} +
+ 价格咨询派单 +
+ {{.Username}} + {{if .PasswordChangeURL}}修改密码{{end}} + +
+
+{{end}} diff --git a/backend/internal/httpapi/templates/login.html b/backend/internal/httpapi/templates/login.html new file mode 100644 index 0000000..5965c3c --- /dev/null +++ b/backend/internal/httpapi/templates/login.html @@ -0,0 +1,16 @@ +{{define "login.html"}} +{{template "admin_head" .}} + +
+ +
+ + +{{end}} diff --git a/backend/internal/httpapi/templates/price_inquiries.html b/backend/internal/httpapi/templates/price_inquiries.html new file mode 100644 index 0000000..4d2f21c --- /dev/null +++ b/backend/internal/httpapi/templates/price_inquiries.html @@ -0,0 +1,132 @@ +{{define "price_inquiries.html"}} +{{template "admin_head" .}} + + {{template "admin_nav" .}} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {{if .HasPriceInquiryFilter}}全部{{end}} +
+
+ +
共 {{.PriceInquiryTotal}} 条咨询,第 {{.PriceInquiryPage}} / {{.PriceInquiryTotalPages}} 页
+ + {{if .Message}}
{{.Message}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+ + + + + + + + + + + + + + + + {{range .PriceInquiryRecords}} + + + + + + + + + + + + {{else}} + + + + {{end}} + +
手机号咨询项目所在地区状态创建时间最近提交时间更新时间备注设置
{{.Phone}}{{.ProjectName}}{{.Region}}{{.StatusText}}{{.CreatedAt}}{{.LastSubmittedAt}}{{.UpdatedAt}}{{if .Remark}}{{.Remark}}{{else}}-{{end}} +
+ 设置 +
+ + + + + + + + +
+ + +
+
+ + +
+ +
+
+
暂无价格咨询数据
+
+ +
+ + +{{end}} diff --git a/backend/internal/httpapi/wechat_phone.go b/backend/internal/httpapi/wechat_phone.go new file mode 100644 index 0000000..21235c0 --- /dev/null +++ b/backend/internal/httpapi/wechat_phone.go @@ -0,0 +1,200 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" +) + +const defaultWeChatAPIBase = "https://api.weixin.qq.com" + +type wechatPhoneClient struct { + appID string + appSecret string + apiBase string + client *http.Client + + mu sync.Mutex + cachedAccessToken string + accessTokenExp time.Time +} + +type missingWeChatConfigError struct { + missing []string +} + +func (e missingWeChatConfigError) Error() string { + return "missing WeChat app configuration: set " + strings.Join(e.missing, ", ") +} + +type wechatAccessTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +type wechatPhoneResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + PhoneInfo struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + CountryCode string `json:"countryCode"` + } `json:"phone_info"` +} + +func newWeChatPhoneClient(cfg config.Config) *wechatPhoneClient { + apiBase := strings.TrimRight(strings.TrimSpace(cfg.WeChatAPIBase), "/") + if apiBase == "" { + apiBase = defaultWeChatAPIBase + } + return &wechatPhoneClient{ + appID: strings.TrimSpace(cfg.WeChatAppID), + appSecret: strings.TrimSpace(cfg.WeChatAppSecret), + apiBase: apiBase, + client: &http.Client{ + Timeout: 8 * time.Second, + }, + } +} + +func (c *wechatPhoneClient) PhoneNumber(ctx context.Context, phoneCode string) (string, error) { + phoneCode = strings.TrimSpace(phoneCode) + if phoneCode == "" { + return "", errors.New("phoneCode is required") + } + token, err := c.accessToken(ctx) + if err != nil { + return "", err + } + + payload, err := json.Marshal(map[string]string{"code": phoneCode}) + if err != nil { + return "", err + } + endpoint := c.apiURL("/wxa/business/getuserphonenumber") + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("request WeChat phone API: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("WeChat phone API returned HTTP %d", resp.StatusCode) + } + + var data wechatPhoneResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", fmt.Errorf("decode WeChat phone response: %w", err) + } + if data.ErrCode != 0 { + return "", fmt.Errorf("WeChat phone API error %d: %s", data.ErrCode, data.ErrMsg) + } + phone := strings.TrimSpace(data.PhoneInfo.PhoneNumber) + if phone == "" { + phone = strings.TrimSpace(data.PhoneInfo.PurePhoneNumber) + } + if phone == "" { + return "", errors.New("WeChat phone API returned empty phone number") + } + return phone, nil +} + +func (c *wechatPhoneClient) accessToken(ctx context.Context) (string, error) { + if err := c.validateConfig(); err != nil { + return "", err + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.cachedAccessToken != "" && time.Now().Before(c.accessTokenExp) { + return c.cachedAccessToken, nil + } + + endpoint := c.apiURL("/cgi-bin/token") + query := endpoint.Query() + query.Set("grant_type", "client_credential") + query.Set("appid", c.appID) + query.Set("secret", c.appSecret) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return "", err + } + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("request WeChat access_token API: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("WeChat access_token API returned HTTP %d", resp.StatusCode) + } + + var data wechatAccessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", fmt.Errorf("decode WeChat access_token response: %w", err) + } + if data.ErrCode != 0 { + return "", fmt.Errorf("WeChat access_token API error %d: %s", data.ErrCode, data.ErrMsg) + } + if strings.TrimSpace(data.AccessToken) == "" { + return "", errors.New("WeChat access_token API returned empty access_token") + } + c.cachedAccessToken = strings.TrimSpace(data.AccessToken) + c.accessTokenExp = tokenExpiry(data.ExpiresIn) + return c.cachedAccessToken, nil +} + +func (c *wechatPhoneClient) validateConfig() error { + missing := make([]string, 0, 2) + if c.appID == "" { + missing = append(missing, "GUAHAO_WECHAT_APPID") + } + if c.appSecret == "" { + missing = append(missing, "GUAHAO_WECHAT_SECRET") + } + if len(missing) > 0 { + return missingWeChatConfigError{missing: missing} + } + return nil +} + +func (c *wechatPhoneClient) apiURL(path string) url.URL { + base, err := url.Parse(c.apiBase) + if err != nil || base.Scheme == "" || base.Host == "" { + base, _ = url.Parse(defaultWeChatAPIBase) + } + base.Path = strings.TrimRight(base.Path, "/") + path + base.RawQuery = "" + return *base +} + +func tokenExpiry(expiresIn int64) time.Time { + if expiresIn <= 0 { + expiresIn = 3600 + } + refreshSkew := int64(300) + if expiresIn <= refreshSkew*2 { + refreshSkew = expiresIn / 2 + } + return time.Now().Add(time.Duration(expiresIn-refreshSkew) * time.Second) +} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go new file mode 100644 index 0000000..f548a2b --- /dev/null +++ b/backend/internal/service/admin.go @@ -0,0 +1,294 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" +) + +const HomeHeroImageConfigKey = "home_hero_image" + +type AdminPhoneRecord struct { + Phone string `json:"phone"` + UserID string `json:"user_id"` + OpenID string `json:"openid"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastLoginAt string `json:"last_login_at"` +} + +type AdminBookingRecord struct { + BookingID string `json:"booking_id"` + Phone string `json:"phone"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + Date string `json:"date"` + StartTime string `json:"start_time"` + Status string `json:"status"` + StatusText string `json:"status_text"` + CreatedAt string `json:"created_at"` +} + +type AdminPriceInquiryRecord struct { + InquiryID string `json:"inquiry_id"` + Phone string `json:"phone"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + Region string `json:"region"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + StatusClass string `json:"status_class"` + StatusText string `json:"status_text"` + Remark string `json:"remark"` + LastSubmittedAt string `json:"last_submitted_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type AdminPriceInquiryFilter struct { + Area string + Phone string + Status string + TimeFrom string + TimeTo string +} + +type AdminPriceInquiryPage struct { + Records []AdminPriceInquiryRecord + Total int64 + Page int + PageSize int + TotalPages int + HasPreviousPage bool + HasNextPage bool + PreviousPage int + NextPage int +} + +func (s *Service) HomeHeroImage(ctx context.Context) (string, error) { + config, err := s.Q.GetAppConfig(ctx, HomeHeroImageConfigKey) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return config.ConfigValue, nil +} + +func (s *Service) SetHomeHeroImage(ctx context.Context, image string) error { + _, err := s.Q.UpsertAppConfig(ctx, db.UpsertAppConfigParams{ + ConfigKey: HomeHeroImageConfigKey, + ConfigValue: image, + }) + return err +} + +func (s *Service) AdminPhoneRecords(ctx context.Context) ([]AdminPhoneRecord, error) { + users, err := s.Q.ListUsersWithPhone(ctx) + if err != nil { + return nil, err + } + + records := make([]AdminPhoneRecord, 0, len(users)) + for _, user := range users { + phone := nullString(user.Phone) + if phone == "" { + continue + } + records = append(records, AdminPhoneRecord{ + Phone: phone, + UserID: user.ID, + OpenID: user.Openid, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + LastLoginAt: user.LastLoginAt, + }) + } + return records, nil +} + +func (s *Service) AdminBookingRecords(ctx context.Context, phone string, limit, offset int64) ([]AdminBookingRecord, error) { + phone = strings.TrimSpace(phone) + var ( + details []BookingDetail + err error + ) + if phone != "" { + details, err = s.ListBookingDetailsByPhone(ctx, phone) + } else { + details, err = s.AdminListBookingDetails(ctx, "", limit, offset) + } + if err != nil { + return nil, err + } + records := make([]AdminBookingRecord, 0, len(details)) + for _, detail := range details { + records = append(records, AdminBookingRecord{ + BookingID: detail.Booking.ID, + Phone: detail.Booking.Phone, + ProjectID: detail.Project.ID, + ProjectName: detail.Project.Name, + Date: detail.Day.Date, + StartTime: detail.Slot.StartTime, + Status: detail.Booking.Status, + StatusText: adminBookingStatusText(detail.Booking.Status), + CreatedAt: detail.Booking.CreatedAt, + }) + } + return records, nil +} + +func (s *Service) AdminPriceInquiryRecords(ctx context.Context, filter AdminPriceInquiryFilter, limit, offset int64) ([]AdminPriceInquiryRecord, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + filter = normalizeAdminPriceInquiryFilter(filter) + inquiries, err := s.Q.ListPriceInquiries(ctx, db.ListPriceInquiriesParams{ + Area: filter.Area, + Phone: filter.Phone, + Status: filter.Status, + TimeFrom: filter.TimeFrom, + TimeTo: filter.TimeTo, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, err + } + return adminPriceInquiryRecordsFromRows(inquiries), nil +} + +func (s *Service) AdminPriceInquiryRecordsPage(ctx context.Context, filter AdminPriceInquiryFilter, page, pageSize int) (AdminPriceInquiryPage, error) { + filter = normalizeAdminPriceInquiryFilter(filter) + total, err := s.Q.CountPriceInquiries(ctx, db.CountPriceInquiriesParams{ + Area: filter.Area, + Phone: filter.Phone, + Status: filter.Status, + TimeFrom: filter.TimeFrom, + TimeTo: filter.TimeTo, + }) + if err != nil { + return AdminPriceInquiryPage{}, err + } + + offset := int64((page - 1) * pageSize) + inquiries, err := s.Q.ListPriceInquiries(ctx, db.ListPriceInquiriesParams{ + Area: filter.Area, + Phone: filter.Phone, + Status: filter.Status, + TimeFrom: filter.TimeFrom, + TimeTo: filter.TimeTo, + Limit: int64(pageSize), + Offset: offset, + }) + if err != nil { + return AdminPriceInquiryPage{}, err + } + + totalPages := 1 + if total > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + result := AdminPriceInquiryPage{ + Records: adminPriceInquiryRecordsFromRows(inquiries), + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + HasPreviousPage: page > 1, + HasNextPage: page < totalPages, + } + if result.HasPreviousPage { + result.PreviousPage = page - 1 + } + if result.HasNextPage { + result.NextPage = page + 1 + } + return result, nil +} + +func adminPriceInquiryRecordsFromRows(inquiries []db.PriceInquiry) []AdminPriceInquiryRecord { + records := make([]AdminPriceInquiryRecord, 0, len(inquiries)) + for _, inquiry := range inquiries { + records = append(records, AdminPriceInquiryRecord{ + InquiryID: inquiry.ID, + Phone: inquiry.Phone, + Province: inquiry.Province, + City: inquiry.City, + District: inquiry.District, + Region: inquiry.Province + " " + inquiry.City + " " + inquiry.District, + ProjectName: inquiry.ProjectName, + Status: inquiry.Status, + StatusClass: adminPriceInquiryStatusClass(inquiry.Status), + StatusText: adminPriceInquiryStatusText(inquiry.Status), + Remark: inquiry.Remark, + LastSubmittedAt: inquiry.LastSubmittedAt, + CreatedAt: inquiry.CreatedAt, + UpdatedAt: inquiry.UpdatedAt, + }) + } + return records +} + +func normalizeAdminPriceInquiryFilter(filter AdminPriceInquiryFilter) AdminPriceInquiryFilter { + return AdminPriceInquiryFilter{ + Area: strings.TrimSpace(filter.Area), + Phone: strings.TrimSpace(filter.Phone), + Status: strings.TrimSpace(filter.Status), + TimeFrom: strings.TrimSpace(filter.TimeFrom), + TimeTo: strings.TrimSpace(filter.TimeTo), + } +} + +func adminBookingStatusText(status string) string { + switch status { + case "pending": + return "待确认" + case "confirmed": + return "已预约" + case "cancelled_by_user": + return "用户已取消" + case "cancelled_by_admin": + return "后台已取消" + default: + return status + } +} + +func adminPriceInquiryStatusText(status string) string { + switch status { + case "pending": + return "待派单" + case "assigned": + return "已经派单" + case "completed": + return "已跟进" + default: + return status + } +} + +func adminPriceInquiryStatusClass(status string) string { + switch status { + case "pending": + return "status-pending" + case "assigned": + return "status-assigned" + case "completed": + return "status-completed" + default: + return "status-unknown" + } +} + +func nullString(value sql.NullString) string { + if !value.Valid { + return "" + } + return value.String +} diff --git a/backend/internal/service/id.go b/backend/internal/service/id.go new file mode 100644 index 0000000..4076725 --- /dev/null +++ b/backend/internal/service/id.go @@ -0,0 +1,16 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +func newID(prefix string) string { + var b [5]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) + } + return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixNano(), hex.EncodeToString(b[:])) +} diff --git a/backend/internal/service/phone_test.go b/backend/internal/service/phone_test.go new file mode 100644 index 0000000..f38b088 --- /dev/null +++ b/backend/internal/service/phone_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "database/sql" + "io" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "github.com/sirupsen/logrus" +) + +func TestBindPhoneReassignsDuplicatePhoneToLatestOpenID(t *testing.T) { + svc, database, closeDB := newTestService(t) + defer closeDB() + + ctx := context.Background() + first, err := svc.Login(ctx, "first") + if err != nil { + t.Fatalf("login first user: %v", err) + } + second, err := svc.Login(ctx, "second") + if err != nil { + t.Fatalf("login second user: %v", err) + } + + if _, err := svc.BindPhone(ctx, first.User.Openid, "13900001111"); err != nil { + t.Fatalf("bind first phone: %v", err) + } + if _, err := svc.BindPhone(ctx, second.User.Openid, "13900001111"); err != nil { + t.Fatalf("bind second phone: %v", err) + } + + var phoneOwners int + if err := database.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM users +WHERE phone = ?`, "13900001111").Scan(&phoneOwners); err != nil { + t.Fatalf("count phone owners: %v", err) + } + if phoneOwners != 1 { + t.Fatalf("phone owner count = %d, want 1", phoneOwners) + } + + firstUser, err := svc.Q.GetUserByOpenID(ctx, first.User.Openid) + if err != nil { + t.Fatalf("reload first user: %v", err) + } + if firstUser.Phone.Valid { + t.Fatalf("first user phone = %#v, want cleared", firstUser.Phone) + } + + records, err := svc.AdminPhoneRecords(ctx) + if err != nil { + t.Fatalf("list admin phone records: %v", err) + } + if len(records) != 1 { + t.Fatalf("admin phone records = %d, want 1", len(records)) + } + if records[0].OpenID != second.User.Openid || records[0].Phone != "13900001111" { + t.Fatalf("admin phone record = %#v, want latest openid with bound phone", records[0]) + } +} + +func TestCreatePriceInquiryUpdatesDuplicateSubmissionTimestamp(t *testing.T) { + svc, database, closeDB := newTestService(t) + defer closeDB() + + ctx := context.Background() + session, err := svc.Login(ctx, "price_inquiry_duplicate") + if err != nil { + t.Fatalf("login user: %v", err) + } + if _, err := svc.BindPhone(ctx, session.User.Openid, "13900005555"); err != nil { + t.Fatalf("bind phone: %v", err) + } + + req := CreatePriceInquiryRequest{ + OpenID: session.User.Openid, + Province: "上海市", + City: "上海市", + District: "浦东新区", + ProjectName: "牙齿种植", + } + first, err := svc.CreatePriceInquiry(ctx, req) + if err != nil { + t.Fatalf("create first inquiry: %v", err) + } + if _, err := database.ExecContext(ctx, ` +UPDATE price_inquiries +SET status = 'assigned', created_at = '2026-07-01 10:00:00', updated_at = '2026-07-01 10:00:00' +WHERE id = ?`, first.ID); err != nil { + t.Fatalf("set deterministic timestamps: %v", err) + } + + second, err := svc.CreatePriceInquiry(ctx, req) + if err != nil { + t.Fatalf("create duplicate inquiry: %v", err) + } + if second.ID != first.ID { + t.Fatalf("duplicate inquiry id = %q, want existing id %q", second.ID, first.ID) + } + if second.CreatedAt != "2026-07-01 10:00:00" { + t.Fatalf("duplicate inquiry created_at = %q, want original created_at", second.CreatedAt) + } + if second.UpdatedAt == "" || second.UpdatedAt == "2026-07-01 10:00:00" { + t.Fatalf("duplicate inquiry updated_at = %q, want refreshed timestamp", second.UpdatedAt) + } + if second.Status != "assigned" { + t.Fatalf("duplicate inquiry status = %q, want assigned", second.Status) + } + + var count int + if err := database.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM price_inquiries +WHERE phone = ? AND province = ? AND city = ? AND district = ? AND project_name = ?`, + "13900005555", "上海市", "上海市", "浦东新区", "牙齿种植").Scan(&count); err != nil { + t.Fatalf("count price inquiries: %v", err) + } + if count != 1 { + t.Fatalf("price inquiry count = %d, want 1", count) + } +} + +func newTestService(t *testing.T) (*Service, *sql.DB, func()) { + t.Helper() + + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + ctx := context.Background() + schemaPath := filepath.Join("..", "..", "migrations", "schema.sql") + if err := ApplySchema(ctx, database, schemaPath); err != nil { + database.Close() + t.Fatalf("apply schema: %v", err) + } + + log := logrus.New() + log.SetOutput(io.Discard) + return New(database, log), database, func() { database.Close() } +} diff --git a/backend/internal/service/price_inquiry_test.go b/backend/internal/service/price_inquiry_test.go new file mode 100644 index 0000000..19c64d8 --- /dev/null +++ b/backend/internal/service/price_inquiry_test.go @@ -0,0 +1,221 @@ +package service + +import ( + "context" + "testing" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" +) + +func TestCreatePriceInquiryRefreshesExistingPhoneOnly(t *testing.T) { + svc, database, closeDB := newTestService(t) + defer closeDB() + + ctx := context.Background() + firstUser, err := svc.Login(ctx, "price_inquiry_first") + if err != nil { + t.Fatalf("login first user: %v", err) + } + if _, err := svc.BindPhone(ctx, firstUser.User.Openid, "13900003333"); err != nil { + t.Fatalf("bind first phone: %v", err) + } + + first, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: firstUser.User.Openid, Province: "上海市", City: "上海市", District: "浦东新区", ProjectName: "牙齿种植", + }) + if err != nil { + t.Fatalf("create first inquiry: %v", err) + } + if _, err := database.ExecContext(ctx, ` +UPDATE price_inquiries +SET status = 'completed', remark = '保留备注', last_submitted_at = '2000-01-01 00:00:00' +WHERE id = ?`, first.ID); err != nil { + t.Fatalf("prepare first inquiry: %v", err) + } + + secondUser, err := svc.Login(ctx, "price_inquiry_second") + if err != nil { + t.Fatalf("login second user: %v", err) + } + if _, err := svc.BindPhone(ctx, secondUser.User.Openid, "13900004444"); err != nil { + t.Fatalf("bind second phone: %v", err) + } + second, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: secondUser.User.Openid, Province: "北京市", City: "北京市", District: "朝阳区", ProjectName: "牙齿美白", + }) + if err != nil { + t.Fatalf("create second phone inquiry: %v", err) + } + if _, err := database.ExecContext(ctx, ` +UPDATE price_inquiries +SET last_submitted_at = '2001-01-01 00:00:00' +WHERE id = ?`, second.ID); err != nil { + t.Fatalf("prepare second inquiry: %v", err) + } + + refreshed, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: firstUser.User.Openid, Province: "浙江省", City: "杭州市", District: "西湖区", ProjectName: "牙齿清洁", + }) + if err != nil { + t.Fatalf("refresh first phone inquiry: %v", err) + } + if refreshed.ID != first.ID || refreshed.Province != "上海市" || refreshed.District != "浦东新区" || refreshed.ProjectName != "牙齿种植" || refreshed.Status != "completed" || refreshed.Remark != "保留备注" { + t.Fatalf("refreshed inquiry changed business fields: %#v", refreshed) + } + if refreshed.LastSubmittedAt <= "2000-01-01 00:00:00" { + t.Fatalf("last_submitted_at = %q, want refreshed time", refreshed.LastSubmittedAt) + } + + var count int + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM price_inquiries WHERE phone = ?", "13900003333").Scan(&count); err != nil { + t.Fatalf("count first phone inquiries: %v", err) + } + if count != 1 { + t.Fatalf("first phone inquiry count = %d, want 1", count) + } + + records, err := svc.AdminPriceInquiryRecords(ctx, AdminPriceInquiryFilter{}, 200, 0) + if err != nil { + t.Fatalf("list admin price inquiries: %v", err) + } + if len(records) != 2 || records[0].Phone != "13900003333" { + t.Fatalf("admin price inquiry order = %#v, want refreshed phone first", records) + } + + updated, err := svc.Q.UpdatePriceInquiry(ctx, db.UpdatePriceInquiryParams{ + ID: first.ID, Status: "assigned", Remark: "上海时间", + }) + if err != nil { + t.Fatalf("update price inquiry settings: %v", err) + } + var timestampIsShanghai bool + if err := database.QueryRowContext(ctx, ` +SELECT ABS((JULIANDAY(?) - JULIANDAY('now', '+8 hours')) * 86400) < 2`, updated.UpdatedAt).Scan(×tampIsShanghai); err != nil { + t.Fatalf("compare updated_at with Shanghai time: %v", err) + } + if !timestampIsShanghai { + t.Fatalf("updated_at = %q, want Shanghai time", updated.UpdatedAt) + } +} + +func TestCreatePriceInquiryDedupesRepeatedPhoneSubmission(t *testing.T) { + svc, database, closeDB := newTestService(t) + defer closeDB() + + ctx := context.Background() + firstUser, err := svc.Login(ctx, "price_inquiry_same_phone_first") + if err != nil { + t.Fatalf("login first user: %v", err) + } + if _, err := svc.BindPhone(ctx, firstUser.User.Openid, "13900006666"); err != nil { + t.Fatalf("bind first phone: %v", err) + } + first, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: firstUser.User.Openid, + Province: "上海市", + City: "上海市", + District: "浦东新区", + ProjectName: "牙齿种植", + }) + if err != nil { + t.Fatalf("create first inquiry: %v", err) + } + if _, err := database.ExecContext(ctx, ` +UPDATE price_inquiries +SET status = 'assigned', remark = '运营已派单', last_submitted_at = '2000-01-01 00:00:00' +WHERE id = ?`, first.ID); err != nil { + t.Fatalf("prepare first inquiry: %v", err) + } + + secondUser, err := svc.Login(ctx, "price_inquiry_same_phone_second") + if err != nil { + t.Fatalf("login second user: %v", err) + } + if _, err := svc.BindPhone(ctx, secondUser.User.Openid, "13900006666"); err != nil { + t.Fatalf("bind repeated phone: %v", err) + } + second, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: secondUser.User.Openid, + Province: "浙江省", + City: "杭州市", + District: "西湖区", + ProjectName: "牙齿清洁", + }) + if err != nil { + t.Fatalf("create repeated phone inquiry: %v", err) + } + if second.ID != first.ID { + t.Fatalf("repeated phone inquiry id = %q, want existing id %q", second.ID, first.ID) + } + if second.Status != "assigned" || second.Remark != "运营已派单" { + t.Fatalf("repeated phone inquiry changed admin fields: %#v", second) + } + if second.Province != "上海市" || second.ProjectName != "牙齿种植" { + t.Fatalf("repeated phone inquiry changed original inquiry fields: %#v", second) + } + if second.LastSubmittedAt <= "2000-01-01 00:00:00" { + t.Fatalf("last_submitted_at = %q, want refreshed timestamp", second.LastSubmittedAt) + } + + var count int + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM price_inquiries WHERE phone = ?", "13900006666").Scan(&count); err != nil { + t.Fatalf("count repeated phone inquiries: %v", err) + } + if count != 1 { + t.Fatalf("repeated phone inquiry count = %d, want 1", count) + } +} + +func TestAdminPriceInquiryTimeFilterMatchesLastSubmittedAt(t *testing.T) { + svc, database, closeDB := newTestService(t) + defer closeDB() + + ctx := context.Background() + user, err := svc.Login(ctx, "price_inquiry_last_submitted_filter") + if err != nil { + t.Fatalf("login user: %v", err) + } + if _, err := svc.BindPhone(ctx, user.User.Openid, "13900007777"); err != nil { + t.Fatalf("bind phone: %v", err) + } + inquiry, err := svc.CreatePriceInquiry(ctx, CreatePriceInquiryRequest{ + OpenID: user.User.Openid, + Province: "上海市", + City: "上海市", + District: "浦东新区", + ProjectName: "牙齿种植", + }) + if err != nil { + t.Fatalf("create inquiry: %v", err) + } + if _, err := database.ExecContext(ctx, ` +UPDATE price_inquiries +SET created_at = '2026-07-01 10:00:00', + updated_at = '2026-07-01 10:00:00', + last_submitted_at = '2026-07-20 10:00:00' +WHERE id = ?`, inquiry.ID); err != nil { + t.Fatalf("prepare inquiry timestamps: %v", err) + } + + records, err := svc.AdminPriceInquiryRecords(ctx, AdminPriceInquiryFilter{ + TimeFrom: "2026-07-20", + TimeTo: "2026-07-20", + }, 200, 0) + if err != nil { + t.Fatalf("filter admin price inquiries: %v", err) + } + if len(records) != 1 || records[0].Phone != "13900007777" { + t.Fatalf("last_submitted_at filter records = %#v, want repeated phone inquiry", records) + } + + records, err = svc.AdminPriceInquiryRecords(ctx, AdminPriceInquiryFilter{ + TimeFrom: "2026-07-02", + TimeTo: "2026-07-19", + }, 200, 0) + if err != nil { + t.Fatalf("filter admin price inquiries outside range: %v", err) + } + if len(records) != 0 { + t.Fatalf("outside time filter records = %#v, want none", records) + } +} diff --git a/backend/internal/service/schema_test.go b/backend/internal/service/schema_test.go new file mode 100644 index 0000000..0520e62 --- /dev/null +++ b/backend/internal/service/schema_test.go @@ -0,0 +1,263 @@ +package service + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" + _ "modernc.org/sqlite" +) + +func TestApplySchemaDropsLegacyConfigColumns(t *testing.T) { + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer database.Close() + + _, err = database.Exec(` +CREATE TABLE project_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + category_id TEXT NOT NULL, + name TEXT NOT NULL, + subtitle TEXT NOT NULL DEFAULT '', + duration_minutes INTEGER NOT NULL DEFAULT 60, + price_text TEXT NOT NULL, + notice TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + sort INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE price_inquiries ( + id TEXT PRIMARY KEY, + user_id TEXT, + phone TEXT NOT NULL, + province TEXT NOT NULL, + city TEXT NOT NULL, + district TEXT NOT NULL, + project_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)`) + if err != nil { + t.Fatalf("create legacy tables: %v", err) + } + + ctx := context.Background() + schemaPath := filepath.Join("..", "..", "migrations", "schema.sql") + if err := ApplySchema(ctx, database, schemaPath); err != nil { + t.Fatalf("apply schema: %v", err) + } + + removedColumns := []struct { + table string + column string + }{ + {table: "project_categories", column: "icon"}, + {table: "projects", column: "duration_minutes"}, + {table: "projects", column: "notice"}, + } + for _, item := range removedColumns { + exists, err := columnExists(ctx, database, item.table, item.column) + if err != nil { + t.Fatalf("check %s.%s: %v", item.table, item.column, err) + } + if exists { + t.Fatalf("%s.%s still exists after schema migration", item.table, item.column) + } + } + + exists, err := columnExists(ctx, database, "projects", "image") + if err != nil { + t.Fatalf("check projects.image: %v", err) + } + if !exists { + t.Fatal("projects.image was not added by schema migration") + } + + exists, err = columnExists(ctx, database, "price_inquiries", "remark") + if err != nil { + t.Fatalf("check price_inquiries.remark: %v", err) + } + if !exists { + t.Fatal("price_inquiries.remark was not added by schema migration") + } + + exists, err = columnExists(ctx, database, "price_inquiries", "last_submitted_at") + if err != nil { + t.Fatalf("check price_inquiries.last_submitted_at: %v", err) + } + if !exists { + t.Fatal("price_inquiries.last_submitted_at was not added by schema migration") + } +} + +func TestApplySchemaDedupesUserPhonesBeforeUniqueIndex(t *testing.T) { + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer database.Close() + + _, err = database.Exec(` +CREATE TABLE users ( + id TEXT PRIMARY KEY, + openid TEXT NOT NULL UNIQUE, + unionid TEXT, + phone TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO users (id, openid, phone, created_at, updated_at, last_login_at) +VALUES + ('user_old', 'openid_old', '13900001111', '2026-07-14 09:00:00', '2026-07-14 09:00:00', '2026-07-14 09:00:00'), + ('user_new', 'openid_new', '13900001111', '2026-07-15 09:00:00', '2026-07-15 09:00:00', '2026-07-15 09:00:00');`) + if err != nil { + t.Fatalf("create duplicate users: %v", err) + } + + ctx := context.Background() + schemaPath := filepath.Join("..", "..", "migrations", "schema.sql") + if err := ApplySchema(ctx, database, schemaPath); err != nil { + t.Fatalf("apply schema: %v", err) + } + + var phoneOwners int + if err := database.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM users +WHERE phone = ?`, "13900001111").Scan(&phoneOwners); err != nil { + t.Fatalf("count phone owners: %v", err) + } + if phoneOwners != 1 { + t.Fatalf("phone owner count = %d, want 1", phoneOwners) + } + + var keptOpenID string + if err := database.QueryRowContext(ctx, ` +SELECT openid +FROM users +WHERE phone = ?`, "13900001111").Scan(&keptOpenID); err != nil { + t.Fatalf("query kept openid: %v", err) + } + if keptOpenID != "openid_new" { + t.Fatalf("kept openid = %q, want openid_new", keptOpenID) + } + + var indexCount int + if err := database.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM sqlite_master +WHERE type = 'index' AND name = 'users_phone_unique_idx'`).Scan(&indexCount); err != nil { + t.Fatalf("check users_phone_unique_idx: %v", err) + } + if indexCount != 1 { + t.Fatalf("users_phone_unique_idx count = %d, want 1", indexCount) + } +} + +func TestApplySchemaDedupesPriceInquiriesBeforeUniqueIndex(t *testing.T) { + database, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer database.Close() + + _, err = database.Exec(` +CREATE TABLE price_inquiries ( + id TEXT PRIMARY KEY, + user_id TEXT, + phone TEXT NOT NULL, + province TEXT NOT NULL, + city TEXT NOT NULL, + district TEXT NOT NULL, + project_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + remark TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO price_inquiries ( + id, phone, province, city, district, project_name, status, remark, created_at, updated_at +) VALUES + ('inquiry_old', '13900002222', '上海市', '上海市', '黄浦区', '牙齿美白', 'assigned', '旧记录', '2026-07-20 09:00:00', '2026-07-20 09:00:00'), + ('inquiry_new', '13900002222', '上海市', '上海市', '浦东新区', '牙齿种植', 'completed', '最新记录', '2026-07-21 09:00:00', '2026-07-22 09:00:00');`) + if err != nil { + t.Fatalf("create duplicate price inquiries: %v", err) + } + + ctx := context.Background() + schemaPath := filepath.Join("..", "..", "migrations", "schema.sql") + if err := ApplySchema(ctx, database, schemaPath); err != nil { + t.Fatalf("apply schema: %v", err) + } + + var id, remark, lastSubmittedAt, createdAt, updatedAt string + if err := database.QueryRowContext(ctx, ` +SELECT id, remark, last_submitted_at, created_at, updated_at +FROM price_inquiries +WHERE phone = ?`, "13900002222").Scan(&id, &remark, &lastSubmittedAt, &createdAt, &updatedAt); err != nil { + t.Fatalf("query kept price inquiry: %v", err) + } + if id != "inquiry_new" || remark != "最新记录" || lastSubmittedAt != "2026-07-21 17:00:00.000" || createdAt != "2026-07-21 17:00:00.000" || updatedAt != "2026-07-22 17:00:00.000" { + t.Fatalf("kept price inquiry = %q/%q/%q/%q/%q, want migrated latest record", id, remark, lastSubmittedAt, createdAt, updatedAt) + } + + _, err = database.ExecContext(ctx, ` +INSERT INTO price_inquiries (id, phone, province, city, district, project_name) +VALUES ('inquiry_duplicate', '13900002222', '上海市', '上海市', '静安区', '牙齿清洁')`) + if err == nil { + t.Fatal("duplicate price inquiry insert succeeded, want unique constraint error") + } + + created, err := db.New(database).UpsertPriceInquiry(ctx, db.UpsertPriceInquiryParams{ + ID: "inquiry_new_phone", Phone: "13900003333", Province: "北京市", City: "北京市", + District: "朝阳区", ProjectName: "牙齿种植", Status: "pending", + }) + if err != nil { + t.Fatalf("create price inquiry after legacy schema upgrade: %v", err) + } + for name, value := range map[string]string{ + "created_at": created.CreatedAt, "updated_at": created.UpdatedAt, "last_submitted_at": created.LastSubmittedAt, + } { + var timestampIsLocal bool + if err := database.QueryRowContext(ctx, ` +SELECT ABS((JULIANDAY(?) - JULIANDAY('now', '+8 hours')) * 86400) < 2`, value).Scan(×tampIsLocal); err != nil { + t.Fatalf("compare price inquiry %s with local time: %v", name, err) + } + if !timestampIsLocal { + t.Fatalf("new price inquiry %s = %q, want local time", name, value) + } + } + + if err := ApplySchema(ctx, database, schemaPath); err != nil { + t.Fatalf("reapply schema: %v", err) + } + var lastSubmittedAtAfterReapply string + if err := database.QueryRowContext(ctx, ` +SELECT last_submitted_at FROM price_inquiries WHERE id = ?`, id).Scan(&lastSubmittedAtAfterReapply); err != nil { + t.Fatalf("query migrated timestamp after reapply: %v", err) + } + if lastSubmittedAtAfterReapply != lastSubmittedAt { + t.Fatalf("last_submitted_at after reapply = %q, want %q", lastSubmittedAtAfterReapply, lastSubmittedAt) + } +} + diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go new file mode 100644 index 0000000..c9ef38e --- /dev/null +++ b/backend/internal/service/service.go @@ -0,0 +1,981 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" + "github.com/sirupsen/logrus" +) + +type Service struct { + DB *sql.DB + Q *db.Queries + Log *logrus.Logger +} + +type CategoryWithProjects struct { + db.ProjectCategory + Projects []db.Project `json:"projects"` +} + +type TimeSlotView struct { + ID string `json:"id"` + StartTime string `json:"start_time"` + Status string `json:"status"` +} + +type ScheduleDayWithSlots struct { + Date string `json:"date"` + Status string `json:"status"` + Slots []TimeSlotView `json:"slots"` +} + +type BookingDetail struct { + Booking db.Booking `json:"booking"` + Project db.Project `json:"project"` + Day db.ScheduleDay `json:"day"` + Slot TimeSlotView `json:"slot"` +} + +type CreateBookingRequest struct { + OpenID string `json:"openid"` + ProjectID string `json:"project_id"` + SlotID string `json:"slot_id"` + Date string `json:"date"` + StartTime string `json:"start_time"` + Phone string `json:"phone"` +} + +type CreatePriceInquiryRequest struct { + OpenID string `json:"openid"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + ProjectName string `json:"project_name"` +} + +type CreateTimeSlotRequest struct { + ScheduleDayID string `json:"schedule_day_id"` + StartTime string `json:"start_time"` + Status string `json:"status"` +} + +type UpdateTimeSlotRequest struct { + StartTime string `json:"start_time"` + Status string `json:"status"` +} + +type SessionResult struct { + User db.User `json:"user"` + SessionID string `json:"session_id"` +} + +const ( + defaultSlotLengthMinutes int64 = 60 + priceInquiryShanghaiTimeMigrationKey = "migration.price_inquiries.asia_shanghai.v1" +) + +var priceInquiryProjects = []string{ + "牙齿矫正", + "牙齿种植", + "牙齿修复", + "牙齿治疗", + "全瓷牙种植", + "牙齿美白", + "正颌", + "瓷贴面", + "美容冠", + "牙齿清洁", +} + +func New(database *sql.DB, logger *logrus.Logger) *Service { + return &Service{ + DB: database, + Q: db.New(database), + Log: logger, + } +} + +func ApplySchema(ctx context.Context, database *sql.DB, schemaPath string) error { + schema, err := os.ReadFile(schemaPath) + if err != nil { + return fmt.Errorf("read schema %s: %w", schemaPath, err) + } + if err := dedupeUserPhones(ctx, database); err != nil { + return fmt.Errorf("dedupe user phones: %w", err) + } + if err := ensurePriceInquiryColumns(ctx, database); err != nil { + return fmt.Errorf("ensure price inquiry columns: %w", err) + } + if err := dedupePriceInquiries(ctx, database); err != nil { + return fmt.Errorf("dedupe price inquiries: %w", err) + } + if _, err := database.ExecContext(ctx, string(schema)); err != nil { + return fmt.Errorf("apply schema: %w", err) + } + if err := ensureSchemaColumns(ctx, database); err != nil { + return fmt.Errorf("ensure schema columns: %w", err) + } + if err := migratePriceInquiryTimesToShanghai(ctx, database); err != nil { + return fmt.Errorf("migrate price inquiry times to Asia/Shanghai: %w", err) + } + if err := dropLegacyConfigColumns(ctx, database); err != nil { + return fmt.Errorf("drop legacy config columns: %w", err) + } + if err := normalizeScheduleStatuses(ctx, database); err != nil { + return fmt.Errorf("normalize schedule statuses: %w", err) + } + return nil +} + +func dedupePriceInquiries(ctx context.Context, database *sql.DB) error { + exists, err := columnExists(ctx, database, "price_inquiries", "phone") + if err != nil || !exists { + return err + } + + _, err = database.ExecContext(ctx, ` +DELETE FROM price_inquiries +WHERE id IN ( + SELECT id + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY phone + ORDER BY last_submitted_at DESC, updated_at DESC, created_at DESC, id DESC + ) AS duplicate_rank + FROM price_inquiries + ) + WHERE duplicate_rank > 1 +)`) + return err +} + +func dedupeUserPhones(ctx context.Context, database *sql.DB) error { + exists, err := columnExists(ctx, database, "users", "phone") + if err != nil || !exists { + return err + } + + rows, err := database.QueryContext(ctx, ` +SELECT id, phone +FROM users +WHERE phone IS NOT NULL AND TRIM(phone) <> '' +ORDER BY phone ASC, updated_at DESC, created_at DESC, id DESC`) + if err != nil { + return err + } + defer rows.Close() + + seen := make(map[string]struct{}) + duplicateIDs := make([]string, 0) + for rows.Next() { + var ( + id string + phone string + ) + if err := rows.Scan(&id, &phone); err != nil { + return err + } + if _, ok := seen[phone]; ok { + duplicateIDs = append(duplicateIDs, id) + continue + } + seen[phone] = struct{}{} + } + if err := rows.Err(); err != nil { + return err + } + if len(duplicateIDs) == 0 { + return nil + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for _, id := range duplicateIDs { + if _, err := tx.ExecContext(ctx, ` +UPDATE users +SET phone = NULL, updated_at = CURRENT_TIMESTAMP +WHERE id = ?`, id); err != nil { + return err + } + } + + return tx.Commit() +} + +func ensurePriceInquiryColumns(ctx context.Context, database *sql.DB) error { + exists, err := tableExists(ctx, database, "price_inquiries") + if err != nil || !exists { + return err + } + if err := addColumnIfMissing(ctx, database, "price_inquiries", "created_at", "created_at TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := addColumnIfMissing(ctx, database, "price_inquiries", "updated_at", "updated_at TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := addColumnIfMissing(ctx, database, "price_inquiries", "remark", "remark TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := addColumnIfMissing(ctx, database, "price_inquiries", "last_submitted_at", "last_submitted_at TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + statements := []string{ + "UPDATE price_inquiries SET created_at = CURRENT_TIMESTAMP WHERE TRIM(created_at) = ''", + "UPDATE price_inquiries SET updated_at = created_at WHERE TRIM(updated_at) = ''", + "UPDATE price_inquiries SET last_submitted_at = created_at WHERE TRIM(last_submitted_at) = ''", + } + for _, statement := range statements { + if _, err := database.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + +func migratePriceInquiryTimesToShanghai(ctx context.Context, database *sql.DB) error { + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var applied bool + if err := tx.QueryRowContext(ctx, ` +SELECT EXISTS(SELECT 1 FROM app_config WHERE config_key = ?)`, priceInquiryShanghaiTimeMigrationKey).Scan(&applied); err != nil { + return err + } + if applied { + return nil + } + if _, err := tx.ExecContext(ctx, ` +UPDATE price_inquiries +SET created_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', created_at, '+8 hours'), created_at), + updated_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', updated_at, '+8 hours'), updated_at), + last_submitted_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', last_submitted_at, '+8 hours'), last_submitted_at)`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO app_config (config_key, config_value) VALUES (?, 'applied')`, priceInquiryShanghaiTimeMigrationKey); err != nil { + return err + } + return tx.Commit() +} + +func ensureSchemaColumns(ctx context.Context, database *sql.DB) error { + if err := addColumnIfMissing(ctx, database, "projects", "image", "image TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return ensurePriceInquiryColumns(ctx, database) +} + +func addColumnIfMissing(ctx context.Context, database *sql.DB, table, column, definition string) error { + exists, err := columnExists(ctx, database, table, column) + if err != nil || exists { + return err + } + _, err = database.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+definition) + return err +} + +func tableExists(ctx context.Context, database *sql.DB, table string) (bool, error) { + var name string + err := database.QueryRowContext(ctx, ` +SELECT name +FROM sqlite_master +WHERE type = 'table' AND name = ?`, table).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func dropLegacyConfigColumns(ctx context.Context, database *sql.DB) error { + columns := []struct { + table string + column string + }{ + {table: "project_categories", column: "icon"}, + {table: "projects", column: "duration_minutes"}, + {table: "projects", column: "notice"}, + } + for _, item := range columns { + exists, err := columnExists(ctx, database, item.table, item.column) + if err != nil { + return err + } + if !exists { + continue + } + if _, err := database.ExecContext(ctx, "ALTER TABLE "+item.table+" DROP COLUMN "+item.column); err != nil { + return err + } + } + return nil +} + +func columnExists(ctx context.Context, database *sql.DB, table, column string) (bool, error) { + rows, err := database.QueryContext(ctx, "PRAGMA table_info("+table+")") + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var ( + cid int64 + name string + columnType string + notNull int64 + defaultVal sql.NullString + pk int64 + ) + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() +} + +func normalizeScheduleStatuses(ctx context.Context, database *sql.DB) error { + statements := []string{ + "UPDATE schedule_days SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'", + "UPDATE time_slots SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'", + } + for _, statement := range statements { + if _, err := database.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + +func EnsureDatabaseDir(path string) error { + if strings.HasPrefix(path, "file:") || path == ":memory:" { + return nil + } + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func (s *Service) Seed(ctx context.Context) error { + count, err := s.Q.CountCategories(ctx) + if err != nil { + return err + } + if count > 0 { + return nil + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + q := s.Q.WithTx(tx) + + categories := []db.CreateCategoryParams{ + {ID: "cat_daily", Name: "日常护理", Sort: 10, Status: "active"}, + {ID: "cat_clean", Name: "深层清洁", Sort: 20, Status: "active"}, + {ID: "cat_white", Name: "美白亮齿", Sort: 30, Status: "active"}, + {ID: "cat_breath", Name: "口气清新", Sort: 40, Status: "active"}, + {ID: "cat_child", Name: "儿童护理", Sort: 50, Status: "active"}, + } + for _, c := range categories { + if _, err := q.CreateCategory(ctx, c); err != nil { + return err + } + } + + projects := []db.CreateProjectParams{ + defaultProject("project_fresh_clean", "cat_daily", "清新洁牙护理", "温和清洁 · 清新口气", "¥398", 10), + defaultProject("project_deep_clean", "cat_clean", "深层洁牙护理", "深层清洁 · 呵护牙龈", "¥598", 20), + defaultProject("project_light_care", "cat_daily", "舒缓护理", "舒缓敏感 · 轻柔呵护", "¥498", 30), + defaultProject("project_bright_white", "cat_white", "亮白护理", "提亮美白 · 光泽自信", "¥698", 40), + defaultProject("project_breath_fresh", "cat_breath", "口气清新护理", "清新口气 · 轻松交流", "到店咨询", 50), + defaultProject("project_child_care", "cat_child", "儿童口腔护理", "轻松陪伴 · 温和体验", "¥298", 60), + } + for _, p := range projects { + if _, err := q.CreateProject(ctx, p); err != nil { + return err + } + } + + base := time.Now().AddDate(0, 0, 1) + slotStarts := []string{"09:30", "10:30", "13:00", "14:00", "18:00", "19:00", "20:00"} + for _, project := range projects { + for i := 0; i < 21; i++ { + day := base.AddDate(0, 0, i) + date := day.Format("2006-01-02") + status := "available" + note := "" + dayID := staticScheduleDayID(project.ID, date) + createdDay, err := q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{ + ID: dayID, ProjectID: project.ID, Date: date, Status: status, Note: note, + }) + if err != nil { + return err + } + for _, start := range slotStarts { + end := addMinutes(start, defaultSlotLengthMinutes) + slotStatus := status + _, err := q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{ + ID: staticSlotID(createdDay.ID, start), + ScheduleDayID: createdDay.ID, + StartTime: start, + EndTime: end, + Status: slotStatus, + }) + if err != nil { + return err + } + } + } + } + return tx.Commit() +} + +func addMinutes(hhmm string, minutes int64) string { + t, err := time.Parse("15:04", hhmm) + if err != nil { + return hhmm + } + return t.Add(time.Duration(minutes) * time.Minute).Format("15:04") +} + +func defaultProject(id, categoryID, name, subtitle, priceText string, sort int64) db.CreateProjectParams { + return db.CreateProjectParams{ + ID: id, + CategoryID: categoryID, + Name: name, + Subtitle: subtitle, + PriceText: priceText, + Status: "active", + Sort: sort, + } +} + +func staticScheduleDayID(projectID, date string) string { + return "day_" + projectID + "_" + strings.ReplaceAll(date, "-", "") +} + +func staticSlotID(scheduleDayID, startTime string) string { + return "slot_" + scheduleDayID + "_" + strings.ReplaceAll(startTime, ":", "") +} + +func (s *Service) CategoriesWithProjects(ctx context.Context, admin bool) ([]CategoryWithProjects, error) { + var ( + categories []db.ProjectCategory + projects []db.Project + err error + ) + if admin { + categories, err = s.Q.ListCategories(ctx) + if err != nil { + return nil, err + } + projects, err = s.Q.ListProjects(ctx) + } else { + categories, err = s.Q.ListActiveCategories(ctx) + if err != nil { + return nil, err + } + projects, err = s.Q.ListActiveProjects(ctx) + } + if err != nil { + return nil, err + } + grouped := make(map[string][]db.Project) + for _, project := range projects { + grouped[project.CategoryID] = append(grouped[project.CategoryID], project) + } + result := make([]CategoryWithProjects, 0, len(categories)) + for _, category := range categories { + result = append(result, CategoryWithProjects{ + ProjectCategory: category, + Projects: grouped[category.ID], + }) + } + return result, nil +} + +func (s *Service) DeleteProject(ctx context.Context, projectID string) error { + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return errors.New("项目 ID 不能为空") + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + qtx := s.Q.WithTx(tx) + if _, err := qtx.GetProject(ctx, projectID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errors.New("项目不存在,请刷新后重试") + } + return err + } + bookingCount, err := qtx.CountBookingsByProject(ctx, projectID) + if err != nil { + return err + } + if bookingCount > 0 { + return errors.New("项目已有预约记录,请先下架项目以保留历史预约") + } + if err := qtx.DeleteTimeSlotsByProject(ctx, projectID); err != nil { + return err + } + if err := qtx.DeleteScheduleDaysByProject(ctx, projectID); err != nil { + return err + } + if err := qtx.DeleteProject(ctx, projectID); err != nil { + return err + } + return tx.Commit() +} + +func (s *Service) ScheduleDays(ctx context.Context, projectID, month string) ([]ScheduleDayWithSlots, error) { + if strings.TrimSpace(projectID) == "" { + return nil, errors.New("projectId is required") + } + if month == "" { + month = time.Now().Format("2006-01") + } + days, err := s.Q.ListScheduleDaysByProjectMonth(ctx, db.ListScheduleDaysByProjectMonthParams{ + ProjectID: projectID, + Month: sql.NullString{String: month, Valid: true}, + }) + if err != nil { + return nil, err + } + result := make([]ScheduleDayWithSlots, 0, len(days)) + for _, day := range days { + slots, err := s.Q.ListTimeSlotsByDay(ctx, day.ID) + if err != nil { + return nil, err + } + slotViews := make([]TimeSlotView, 0, len(slots)) + for _, slot := range slots { + slotViews = append(slotViews, toSlotView(slot)) + } + result = append(result, ScheduleDayWithSlots{ + Date: day.Date, Status: day.Status, Slots: slotViews, + }) + } + return result, nil +} + +func (s *Service) Login(ctx context.Context, code string) (SessionResult, error) { + code = strings.TrimSpace(code) + if code == "" { + code = "demo" + } + openid := "demo_openid_" + code + user, err := s.Q.GetUserByOpenID(ctx, openid) + if err == nil { + user, err = s.Q.TouchUserLogin(ctx, openid) + return SessionResult{User: user, SessionID: newID("session")}, err + } + if !errors.Is(err, sql.ErrNoRows) { + return SessionResult{}, err + } + user, err = s.Q.CreateUser(ctx, db.CreateUserParams{ + ID: newID("user"), Openid: openid, + Unionid: sql.NullString{}, Phone: sql.NullString{}, + }) + return SessionResult{User: user, SessionID: newID("session")}, err +} + +func (s *Service) BindPhone(ctx context.Context, openid, phone string) (db.User, error) { + phone = strings.TrimSpace(phone) + if phone == "" { + return db.User{}, errors.New("phone is required") + } + openid = strings.TrimSpace(openid) + if openid == "" { + openid = "demo_openid_demo" + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return db.User{}, err + } + defer tx.Rollback() + qtx := s.Q.WithTx(tx) + + if _, err := tx.ExecContext(ctx, ` +UPDATE users +SET phone = NULL, updated_at = CURRENT_TIMESTAMP +WHERE phone = ? AND openid <> ?`, phone, openid); err != nil { + return db.User{}, err + } + + user, err := qtx.GetUserByOpenID(ctx, openid) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return db.User{}, err + } + user, err = qtx.CreateUser(ctx, db.CreateUserParams{ + ID: newID("user"), + Openid: openid, + Unionid: sql.NullString{}, + Phone: sql.NullString{String: phone, Valid: true}, + }) + if err != nil { + return db.User{}, err + } + if err := tx.Commit(); err != nil { + return db.User{}, err + } + return user, nil + } + user, err = qtx.BindUserPhone(ctx, db.BindUserPhoneParams{ + ID: user.ID, + Phone: sql.NullString{String: phone, Valid: true}, + }) + if err != nil { + return db.User{}, err + } + if err := tx.Commit(); err != nil { + return db.User{}, err + } + return user, nil +} + +func (s *Service) CreatePriceInquiry(ctx context.Context, req CreatePriceInquiryRequest) (db.PriceInquiry, error) { + province := strings.TrimSpace(req.Province) + city := strings.TrimSpace(req.City) + district := strings.TrimSpace(req.District) + projectName := strings.TrimSpace(req.ProjectName) + openid := strings.TrimSpace(req.OpenID) + if province == "" || city == "" || district == "" { + return db.PriceInquiry{}, errors.New("province, city and district are required") + } + if !isPriceInquiryProject(projectName) { + return db.PriceInquiry{}, errors.New("unsupported price inquiry project") + } + if openid == "" { + return db.PriceInquiry{}, errors.New("openid is required") + } + + user, err := s.Q.GetUserByOpenID(ctx, openid) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return db.PriceInquiry{}, errors.New("phone authorization is required") + } + return db.PriceInquiry{}, err + } + phone := nullString(user.Phone) + if phone == "" { + return db.PriceInquiry{}, errors.New("phone authorization is required") + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return db.PriceInquiry{}, fmt.Errorf("begin price inquiry transaction: %w", err) + } + defer tx.Rollback() + qtx := s.Q.WithTx(tx) + inquiry, err := qtx.UpsertPriceInquiry(ctx, db.UpsertPriceInquiryParams{ + ID: newID("price_inquiry"), + UserID: sql.NullString{String: user.ID, Valid: true}, + Phone: phone, + Province: province, + City: city, + District: district, + ProjectName: projectName, + Status: "pending", + }) + if err != nil { + return db.PriceInquiry{}, err + } + if err := tx.Commit(); err != nil { + return db.PriceInquiry{}, fmt.Errorf("commit price inquiry transaction: %w", err) + } + return inquiry, nil +} + +func isPriceInquiryProject(name string) bool { + for _, project := range priceInquiryProjects { + if name == project { + return true + } + } + return false +} + +func (s *Service) CreateBooking(ctx context.Context, req CreateBookingRequest) (BookingDetail, error) { + projectID := strings.TrimSpace(req.ProjectID) + phone := strings.TrimSpace(req.Phone) + if projectID == "" { + return BookingDetail{}, errors.New("projectId is required") + } + if phone == "" { + return BookingDetail{}, errors.New("phone is required") + } + req.ProjectID = projectID + req.Phone = phone + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return BookingDetail{}, err + } + defer tx.Rollback() + qtx := s.Q.WithTx(tx) + + day, slot, err := ensureBookingSlot(ctx, qtx, req) + if err != nil { + return BookingDetail{}, err + } + userID := sql.NullString{} + if req.OpenID != "" { + if user, err := qtx.GetUserByOpenID(ctx, req.OpenID); err == nil { + userID = sql.NullString{String: user.ID, Valid: true} + } + } + booking, err := qtx.CreateBooking(ctx, db.CreateBookingParams{ + ID: newID("booking"), + UserID: userID, + ProjectID: projectID, + SlotID: slot.ID, + Phone: phone, + Status: "pending", + Remark: "", + ConfirmToken: newID("confirm"), + }) + if err != nil { + return BookingDetail{}, err + } + if err := tx.Commit(); err != nil { + return BookingDetail{}, err + } + project, err := s.Q.GetProject(ctx, projectID) + if err != nil { + return BookingDetail{}, err + } + return BookingDetail{ + Booking: booking, + Project: project, + Day: day, + Slot: toSlotView(slot), + }, nil +} + +func ensureBookingSlot(ctx context.Context, q *db.Queries, req CreateBookingRequest) (db.ScheduleDay, db.TimeSlot, error) { + projectID := strings.TrimSpace(req.ProjectID) + slotID := strings.TrimSpace(req.SlotID) + if slotID != "" { + slot, err := q.GetTimeSlot(ctx, slotID) + if err == nil { + day, err := q.GetScheduleDay(ctx, slot.ScheduleDayID) + if err != nil { + return db.ScheduleDay{}, db.TimeSlot{}, err + } + if day.ProjectID != projectID { + return db.ScheduleDay{}, db.TimeSlot{}, errors.New("slot does not belong to project") + } + return day, slot, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return db.ScheduleDay{}, db.TimeSlot{}, err + } + } + + date := strings.TrimSpace(req.Date) + startTime := strings.TrimSpace(req.StartTime) + if date == "" || startTime == "" { + return db.ScheduleDay{}, db.TimeSlot{}, errors.New("date and start_time are required") + } + + dayID := staticScheduleDayID(projectID, date) + day, err := q.GetScheduleDay(ctx, dayID) + if errors.Is(err, sql.ErrNoRows) { + day, err = q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{ + ID: dayID, + ProjectID: projectID, + Date: date, + Status: "available", + Note: "", + }) + } + if err != nil { + return db.ScheduleDay{}, db.TimeSlot{}, err + } + + slotID = staticSlotID(day.ID, startTime) + slot, err := q.GetTimeSlot(ctx, slotID) + if errors.Is(err, sql.ErrNoRows) { + slot, err = q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{ + ID: slotID, + ScheduleDayID: day.ID, + StartTime: startTime, + EndTime: addMinutes(startTime, defaultSlotLengthMinutes), + Status: "available", + }) + } + if err != nil { + return db.ScheduleDay{}, db.TimeSlot{}, err + } + return day, slot, nil +} + +func (s *Service) BookingDetail(ctx context.Context, booking db.Booking) (BookingDetail, error) { + project, err := s.Q.GetProject(ctx, booking.ProjectID) + if err != nil { + return BookingDetail{}, err + } + slot, err := s.Q.GetTimeSlot(ctx, booking.SlotID) + if err != nil { + return BookingDetail{}, err + } + day, err := s.Q.GetScheduleDay(ctx, slot.ScheduleDayID) + if err != nil { + return BookingDetail{}, err + } + return BookingDetail{Booking: booking, Project: project, Day: day, Slot: toSlotView(slot)}, nil +} + +func (s *Service) ListBookingDetailsByPhone(ctx context.Context, phone string) ([]BookingDetail, error) { + bookings, err := s.Q.ListBookingsByPhone(ctx, phone) + if err != nil { + return nil, err + } + details := make([]BookingDetail, 0, len(bookings)) + for _, booking := range bookings { + detail, err := s.BookingDetail(ctx, booking) + if err != nil { + return nil, err + } + details = append(details, detail) + } + return details, nil +} + +func (s *Service) CancelBooking(ctx context.Context, id, phone string) (BookingDetail, error) { + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return BookingDetail{}, err + } + defer tx.Rollback() + qtx := s.Q.WithTx(tx) + booking, err := qtx.CancelBooking(ctx, db.CancelBookingParams{ID: id, Phone: phone}) + if err != nil { + return BookingDetail{}, err + } + if err := tx.Commit(); err != nil { + return BookingDetail{}, err + } + return s.BookingDetail(ctx, booking) +} + +func (s *Service) AdminListBookingDetails(ctx context.Context, status string, limit, offset int64) ([]BookingDetail, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var ( + bookings []db.Booking + err error + ) + if status != "" { + bookings, err = s.Q.ListBookingsByStatus(ctx, db.ListBookingsByStatusParams{Status: status, Limit: limit, Offset: offset}) + } else { + bookings, err = s.Q.ListBookings(ctx, db.ListBookingsParams{Limit: limit, Offset: offset}) + } + if err != nil { + return nil, err + } + details := make([]BookingDetail, 0, len(bookings)) + for _, booking := range bookings { + detail, err := s.BookingDetail(ctx, booking) + if err != nil { + return nil, err + } + details = append(details, detail) + } + return details, nil +} + +func (s *Service) CreateTimeSlot(ctx context.Context, req CreateTimeSlotRequest) (TimeSlotView, error) { + if strings.TrimSpace(req.ScheduleDayID) == "" || strings.TrimSpace(req.StartTime) == "" { + return TimeSlotView{}, errors.New("scheduleDayId and startTime are required") + } + if req.Status == "" { + req.Status = "available" + } + endTime, err := s.slotEndTime(ctx, req.ScheduleDayID, req.StartTime) + if err != nil { + return TimeSlotView{}, err + } + slotID := "slot_" + req.ScheduleDayID + "_" + strings.ReplaceAll(req.StartTime, ":", "") + slot, err := s.Q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{ + ID: slotID, + ScheduleDayID: req.ScheduleDayID, + StartTime: req.StartTime, + EndTime: endTime, + Status: req.Status, + }) + if err != nil { + return TimeSlotView{}, err + } + return toSlotView(slot), nil +} + +func (s *Service) UpdateTimeSlot(ctx context.Context, id string, req UpdateTimeSlotRequest) (TimeSlotView, error) { + if strings.TrimSpace(id) == "" { + return TimeSlotView{}, errors.New("slot id is required") + } + slot, err := s.Q.GetTimeSlot(ctx, id) + if err != nil { + return TimeSlotView{}, err + } + if req.StartTime == "" { + req.StartTime = slot.StartTime + } + if req.Status == "" { + req.Status = slot.Status + } + endTime, err := s.slotEndTime(ctx, slot.ScheduleDayID, req.StartTime) + if err != nil { + return TimeSlotView{}, err + } + updated, err := s.Q.UpdateTimeSlot(ctx, db.UpdateTimeSlotParams{ + ID: id, + StartTime: req.StartTime, + EndTime: endTime, + Status: req.Status, + }) + if err != nil { + return TimeSlotView{}, err + } + return toSlotView(updated), nil +} + +func (s *Service) slotEndTime(ctx context.Context, scheduleDayID, startTime string) (string, error) { + if _, err := s.Q.GetScheduleDay(ctx, scheduleDayID); err != nil { + return "", err + } + return addMinutes(startTime, defaultSlotLengthMinutes), nil +} + +func toSlotView(slot db.TimeSlot) TimeSlotView { + return TimeSlotView{ + ID: slot.ID, + StartTime: slot.StartTime, + Status: slot.Status, + } +} diff --git a/backend/internal/service/views.go b/backend/internal/service/views.go new file mode 100644 index 0000000..ad010ad --- /dev/null +++ b/backend/internal/service/views.go @@ -0,0 +1,83 @@ +package service + +import "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" + +type ProjectView struct { + ID string `json:"id"` + CategoryID string `json:"category_id"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + PriceText string `json:"price_text"` + Image string `json:"image"` +} + +type CategoryView struct { + ID string `json:"id"` + Name string `json:"name"` + Projects []ProjectView `json:"projects"` +} + +type BookingView struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type BookingDayView struct { + Date string `json:"date"` +} + +type BookingDetailView struct { + Booking BookingView `json:"booking"` + Project ProjectView `json:"project"` + Day BookingDayView `json:"day"` + Slot TimeSlotView `json:"slot"` +} + +func ToProjectView(project db.Project) ProjectView { + return ProjectView{ + ID: project.ID, + CategoryID: project.CategoryID, + Name: project.Name, + Subtitle: project.Subtitle, + PriceText: project.PriceText, + Image: project.Image, + } +} + +func CategoryViews(categories []CategoryWithProjects) []CategoryView { + views := make([]CategoryView, 0, len(categories)) + for _, category := range categories { + projects := make([]ProjectView, 0, len(category.Projects)) + for _, project := range category.Projects { + projects = append(projects, ToProjectView(project)) + } + views = append(views, CategoryView{ + ID: category.ID, + Name: category.Name, + Projects: projects, + }) + } + return views +} + +func BookingDetailToView(detail BookingDetail) BookingDetailView { + return BookingDetailView{ + Booking: BookingView{ + ID: detail.Booking.ID, + Status: detail.Booking.Status, + }, + Project: ToProjectView(detail.Project), + Day: BookingDayView{ + Date: detail.Day.Date, + }, + Slot: detail.Slot, + } +} + +func BookingDetailsToViews(details []BookingDetail) []BookingDetailView { + views := make([]BookingDetailView, 0, len(details)) + for _, detail := range details { + views = append(views, BookingDetailToView(detail)) + } + return views +} diff --git a/backend/migrations/schema.sql b/backend/migrations/schema.sql new file mode 100644 index 0000000..4451c9c --- /dev/null +++ b/backend/migrations/schema.sql @@ -0,0 +1,118 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + openid TEXT NOT NULL UNIQUE, + unionid TEXT, + phone TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique_idx +ON users(phone) +WHERE phone IS NOT NULL AND phone <> ''; + +CREATE TABLE IF NOT EXISTS project_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + category_id TEXT NOT NULL REFERENCES project_categories(id), + name TEXT NOT NULL, + subtitle TEXT NOT NULL DEFAULT '', + price_text TEXT NOT NULL, + image TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + sort INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS app_config ( + config_key TEXT PRIMARY KEY, + config_value TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS schedule_days ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'available', + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_id, date) +); + +CREATE TABLE IF NOT EXISTS time_slots ( + id TEXT PRIMARY KEY, + schedule_day_id TEXT NOT NULL REFERENCES schedule_days(id), + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'available', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(schedule_day_id, start_time) +); + +CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + project_id TEXT NOT NULL REFERENCES projects(id), + slot_id TEXT NOT NULL REFERENCES time_slots(id), + phone TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + remark TEXT NOT NULL DEFAULT '', + confirm_token TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS price_inquiries ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + phone TEXT NOT NULL, + province TEXT NOT NULL, + city TEXT NOT NULL, + district TEXT NOT NULL, + project_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + remark TEXT NOT NULL DEFAULT '', + last_submitted_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours')), + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours')), + updated_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS price_inquiries_phone_unique_idx +ON price_inquiries(phone); + +CREATE UNIQUE INDEX IF NOT EXISTS bookings_effective_unique_idx +ON bookings(phone, project_id, slot_id) +WHERE status IN ('pending', 'confirmed'); + +CREATE TABLE IF NOT EXISTS config_audit_logs ( + id TEXT PRIMARY KEY, + admin_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + action TEXT NOT NULL, + before_json TEXT NOT NULL DEFAULT '{}', + after_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS projects_category_idx ON projects(category_id, sort); +CREATE INDEX IF NOT EXISTS schedule_days_project_date_idx ON schedule_days(project_id, date); +CREATE INDEX IF NOT EXISTS time_slots_day_idx ON time_slots(schedule_day_id, start_time); +CREATE INDEX IF NOT EXISTS bookings_phone_idx ON bookings(phone, created_at DESC); +CREATE INDEX IF NOT EXISTS bookings_status_idx ON bookings(status, created_at DESC); +CREATE INDEX IF NOT EXISTS price_inquiries_region_idx ON price_inquiries(province, city, district, created_at DESC); diff --git a/backend/queries/app_config.sql b/backend/queries/app_config.sql new file mode 100644 index 0000000..b24b88d --- /dev/null +++ b/backend/queries/app_config.sql @@ -0,0 +1,11 @@ +-- name: GetAppConfig :one +SELECT * FROM app_config +WHERE config_key = ?; + +-- name: UpsertAppConfig :one +INSERT INTO app_config (config_key, config_value) +VALUES (?, ?) +ON CONFLICT(config_key) DO UPDATE +SET config_value = excluded.config_value, + updated_at = CURRENT_TIMESTAMP +RETURNING *; diff --git a/backend/queries/audit_logs.sql b/backend/queries/audit_logs.sql new file mode 100644 index 0000000..294cd3e --- /dev/null +++ b/backend/queries/audit_logs.sql @@ -0,0 +1,7 @@ +-- name: CreateConfigAuditLog :one +INSERT INTO config_audit_logs ( + id, admin_id, target_type, target_id, action, before_json, after_json +) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING *; + diff --git a/backend/queries/bookings.sql b/backend/queries/bookings.sql new file mode 100644 index 0000000..ecc2158 --- /dev/null +++ b/backend/queries/bookings.sql @@ -0,0 +1,40 @@ +-- name: CreateBooking :one +INSERT INTO bookings (id, user_id, project_id, slot_id, phone, status, remark, confirm_token) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetBooking :one +SELECT * FROM bookings +WHERE id = ?; + +-- name: ListBookingsByPhone :many +SELECT * FROM bookings +WHERE phone = ? +ORDER BY created_at DESC; + +-- name: ListBookings :many +SELECT * FROM bookings +ORDER BY created_at DESC +LIMIT ? OFFSET ?; + +-- name: ListBookingsByStatus :many +SELECT * FROM bookings +WHERE status = ? +ORDER BY created_at DESC +LIMIT ? OFFSET ?; + +-- name: CountBookingsByProject :one +SELECT COUNT(*) FROM bookings +WHERE project_id = ?; + +-- name: UpdateBookingStatus :one +UPDATE bookings +SET status = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: CancelBooking :one +UPDATE bookings +SET status = 'cancelled_by_user', updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND phone = ? AND status IN ('pending', 'confirmed') +RETURNING *; diff --git a/backend/queries/categories.sql b/backend/queries/categories.sql new file mode 100644 index 0000000..fd973f3 --- /dev/null +++ b/backend/queries/categories.sql @@ -0,0 +1,26 @@ +-- name: CountCategories :one +SELECT COUNT(*) FROM project_categories; + +-- name: ListCategories :many +SELECT * FROM project_categories +ORDER BY sort ASC, created_at ASC; + +-- name: ListActiveCategories :many +SELECT * FROM project_categories +WHERE status = 'active' +ORDER BY sort ASC, created_at ASC; + +-- name: CreateCategory :one +INSERT INTO project_categories (id, name, sort, status) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateCategory :one +UPDATE project_categories +SET name = ?, sort = ?, status = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: DeleteCategory :exec +DELETE FROM project_categories +WHERE id = ?; diff --git a/backend/queries/price_inquiries.sql b/backend/queries/price_inquiries.sql new file mode 100644 index 0000000..77dfdf1 --- /dev/null +++ b/backend/queries/price_inquiries.sql @@ -0,0 +1,75 @@ +-- name: UpsertPriceInquiry :one +INSERT INTO price_inquiries ( + id, user_id, phone, province, city, district, project_name, status, + last_submitted_at, created_at, updated_at +) +VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +) +ON CONFLICT(phone) DO UPDATE SET + last_submitted_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours'), + updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +RETURNING id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at; + +-- name: ListPriceInquiries :many +SELECT id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at FROM price_inquiries +WHERE ( + sqlc.arg(area) = '' + OR province LIKE '%' || sqlc.arg(area) || '%' + OR city LIKE '%' || sqlc.arg(area) || '%' + OR district LIKE '%' || sqlc.arg(area) || '%' +) +AND (sqlc.arg(phone) = '' OR phone LIKE '%' || sqlc.arg(phone) || '%') +AND (sqlc.arg(status) = '' OR status = sqlc.arg(status)) +AND ( + (sqlc.arg(time_from) = '' AND sqlc.arg(time_to) = '') + OR ( + (sqlc.arg(time_from) = '' OR created_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR created_at <= sqlc.arg(time_to) || ' 23:59:59') + ) + OR ( + (sqlc.arg(time_from) = '' OR updated_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR updated_at <= sqlc.arg(time_to) || ' 23:59:59') + ) + OR ( + (sqlc.arg(time_from) = '' OR last_submitted_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR last_submitted_at <= sqlc.arg(time_to) || ' 23:59:59') + ) +) +ORDER BY last_submitted_at DESC, updated_at DESC, created_at DESC +LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); + +-- name: CountPriceInquiries :one +SELECT COUNT(*) FROM price_inquiries +WHERE ( + sqlc.arg(area) = '' + OR province LIKE '%' || sqlc.arg(area) || '%' + OR city LIKE '%' || sqlc.arg(area) || '%' + OR district LIKE '%' || sqlc.arg(area) || '%' +) +AND (sqlc.arg(phone) = '' OR phone LIKE '%' || sqlc.arg(phone) || '%') +AND (sqlc.arg(status) = '' OR status = sqlc.arg(status)) +AND ( + (sqlc.arg(time_from) = '' AND sqlc.arg(time_to) = '') + OR ( + (sqlc.arg(time_from) = '' OR created_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR created_at <= sqlc.arg(time_to) || ' 23:59:59') + ) + OR ( + (sqlc.arg(time_from) = '' OR updated_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR updated_at <= sqlc.arg(time_to) || ' 23:59:59') + ) + OR ( + (sqlc.arg(time_from) = '' OR last_submitted_at >= sqlc.arg(time_from) || ' 00:00:00') + AND (sqlc.arg(time_to) = '' OR last_submitted_at <= sqlc.arg(time_to) || ' 23:59:59') + ) +); + +-- name: UpdatePriceInquiry :one +UPDATE price_inquiries +SET status = ?, remark = ?, updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', '+8 hours') +WHERE id = ? +RETURNING id, user_id, phone, province, city, district, project_name, status, remark, last_submitted_at, created_at, updated_at; diff --git a/backend/queries/projects.sql b/backend/queries/projects.sql new file mode 100644 index 0000000..eeb91db --- /dev/null +++ b/backend/queries/projects.sql @@ -0,0 +1,45 @@ +-- name: ListProjects :many +SELECT * FROM projects +ORDER BY sort ASC, created_at ASC; + +-- name: ListActiveProjects :many +SELECT * FROM projects +WHERE status = 'active' +ORDER BY sort ASC, created_at ASC; + +-- name: ListActiveProjectsByCategory :many +SELECT * FROM projects +WHERE category_id = ? AND status = 'active' +ORDER BY sort ASC, created_at ASC; + +-- name: CountProjectsByCategory :one +SELECT COUNT(*) FROM projects +WHERE category_id = ?; + +-- name: GetProject :one +SELECT * FROM projects +WHERE id = ?; + +-- name: CreateProject :one +INSERT INTO projects ( + id, category_id, name, subtitle, price_text, image, status, sort +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: UpdateProject :one +UPDATE projects +SET category_id = ?, + name = ?, + subtitle = ?, + price_text = ?, + image = ?, + status = ?, + sort = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: DeleteProject :exec +DELETE FROM projects +WHERE id = ?; diff --git a/backend/queries/schedules.sql b/backend/queries/schedules.sql new file mode 100644 index 0000000..4bc6e39 --- /dev/null +++ b/backend/queries/schedules.sql @@ -0,0 +1,53 @@ +-- name: ListScheduleDaysByProjectMonth :many +SELECT * FROM schedule_days +WHERE project_id = sqlc.arg(project_id) AND date LIKE sqlc.arg(month) || '%' +ORDER BY date ASC; + +-- name: GetScheduleDay :one +SELECT * FROM schedule_days +WHERE id = ?; + +-- name: CreateScheduleDay :one +INSERT INTO schedule_days (id, project_id, date, status, note) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: UpdateScheduleDay :one +UPDATE schedule_days +SET status = ?, note = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: ListTimeSlotsByDay :many +SELECT * FROM time_slots +WHERE schedule_day_id = ? +ORDER BY start_time ASC; + +-- name: GetTimeSlot :one +SELECT * FROM time_slots +WHERE id = ?; + +-- name: CreateTimeSlot :one +INSERT INTO time_slots (id, schedule_day_id, start_time, end_time, status) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: UpdateTimeSlot :one +UPDATE time_slots +SET start_time = ?, + end_time = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: DeleteTimeSlotsByProject :exec +DELETE FROM time_slots +WHERE schedule_day_id IN ( + SELECT id FROM schedule_days + WHERE project_id = ? +); + +-- name: DeleteScheduleDaysByProject :exec +DELETE FROM schedule_days +WHERE project_id = ?; diff --git a/backend/queries/users.sql b/backend/queries/users.sql new file mode 100644 index 0000000..eef6d3a --- /dev/null +++ b/backend/queries/users.sql @@ -0,0 +1,39 @@ +-- name: GetUserByOpenID :one +SELECT * FROM users +WHERE openid = ?; + +-- name: GetUserByID :one +SELECT * FROM users +WHERE id = ?; + +-- name: CreateUser :one +INSERT INTO users (id, openid, unionid, phone) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: TouchUserLogin :one +UPDATE users +SET last_login_at = CURRENT_TIMESTAMP +WHERE openid = ? +RETURNING *; + +-- name: BindUserPhone :one +UPDATE users +SET phone = ?, updated_at = CURRENT_TIMESTAMP +WHERE id = ? +RETURNING *; + +-- name: ListUsersWithPhone :many +SELECT * FROM users +WHERE phone IS NOT NULL AND phone <> '' +ORDER BY updated_at DESC, created_at DESC; + +-- name: ListBookingPhoneSummaries :many +SELECT + phone, + COUNT(*) AS booking_count, + CAST(MAX(created_at) AS TEXT) AS last_booking_at +FROM bookings +WHERE phone <> '' +GROUP BY phone +ORDER BY last_booking_at DESC, phone ASC; diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000..32ff330 --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,15 @@ +version: "2" +sql: + - engine: "sqlite" + schema: "migrations/schema.sql" + queries: "queries" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "database/sql" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: false + emit_exact_table_names: false + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5988321 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + smilefirst-api: + image: rogeecn/weixin-guahao:latest + container_name: smilefirst-api + environment: + GUAHAO_ADDR: ":9800" + GUAHAO_DB_PATH: "/data/smilefirst.db" + GUAHAO_UPLOAD_DIR: "/data/uploads" + GUAHAO_SCHEMA_PATH: "/app/migrations/schema.sql" + GUAHAO_LOG_LEVEL: "info" + GUAHAO_ADMIN_AUTH_MODE: "${GUAHAO_ADMIN_AUTH_MODE:?Set GUAHAO_ADMIN_AUTH_MODE to none or oidc}" + GUAHAO_ADMIN_SESSION_SECRET: "${GUAHAO_ADMIN_SESSION_SECRET:-}" + GUAHAO_OIDC_ISSUER: "${GUAHAO_OIDC_ISSUER:-}" + GUAHAO_OIDC_CLIENT_ID: "${GUAHAO_OIDC_CLIENT_ID:-}" + GUAHAO_OIDC_CLIENT_SECRET: "${GUAHAO_OIDC_CLIENT_SECRET:-}" + GUAHAO_OIDC_REDIRECT_URI: "${GUAHAO_OIDC_REDIRECT_URI:-}" + GUAHAO_OIDC_SCOPES: "${GUAHAO_OIDC_SCOPES:-openid profile email phone}" + GUAHAO_WECHAT_APPID: "${GUAHAO_WECHAT_APPID:-}" + GUAHAO_WECHAT_SECRET: "${GUAHAO_WECHAT_SECRET:-}" + TZ: "Asia/Shanghai" + ports: + - "9800:9800" + volumes: + - smilefirst_data:/data + restart: unless-stopped + +volumes: + smilefirst_data: diff --git a/docs/design-elements.md b/docs/design-elements.md new file mode 100644 index 0000000..38e8c7b --- /dev/null +++ b/docs/design-elements.md @@ -0,0 +1,55 @@ +# Smile First 设计元素提取清单 + +来源:`docs/smile-first-ai-visual-board-5-pages.png` +尺寸:`1692 x 929` + +## 视觉原则 + +- 首页删除视觉稿里的底部 tabBar,只保留内容区入口与主按钮。 +- 首页 `立即预约` 先触发手机号授权检查,授权成功后进入项目选择;确认预约页只展示已授权手机号。 +- 页面文案保持口腔护理预约叙事,不使用诊疗、问诊、疗效承诺类表达。 + +## 色彩 token + +| 用途 | 色值 | +| --- | --- | +| 页面背景 | `#F7F2EA` | +| 表面色 | `#FFFDF8` | +| 主文字 | `#12201E` | +| 品牌色 | `#153D37` | +| 主操作 | `#0F6F63` | +| 辅助薄荷 | `#BFE7DE` | +| 雾蓝灰 | `#DDE9EC` | +| 暖强调 | `#AA573E` | +| 次文字 | `#5A706B` | +| 分割线 | `#D9E5DF` | + +## 已裁切资产 + +| 资产 | 输出路径 | 裁切坐标 | +| --- | --- | --- | +| 首页参考屏 | `miniapp/assets/reference/home-screen.png` | `(55, 245, 333, 835)` | +| 项目页参考屏 | `miniapp/assets/reference/projects-screen.png` | `(379, 245, 653, 835)` | +| 日期时间参考屏 | `miniapp/assets/reference/schedule-screen.png` | `(704, 245, 979, 835)` | +| 确认预约参考屏 | `miniapp/assets/reference/confirm-screen.png` | `(1030, 245, 1305, 835)` | +| 成功页参考屏 | `miniapp/assets/reference/success-screen.png` | `(1356, 245, 1630, 835)` | +| 首页笑容主图 | `miniapp/assets/extracted/home-hero-smile.png` | `(56, 309, 332, 562)` | +| 成功勾选图标 | `miniapp/assets/extracted/success-check.png` | `(1430, 322, 1530, 402)` | +| 品牌印章 | `miniapp/assets/extracted/brand-seal.png` | `(1340, 55, 1438, 154)` | +| 首页预约图标 | `miniapp/assets/extracted/icon-calendar.png` | `(112, 668, 146, 704)` | +| 我的预约图标 | `miniapp/assets/extracted/icon-booking-card.png` | `(238, 668, 272, 704)` | +| 底部叶片与瓷碗 | `miniapp/assets/extracted/bottom-leaves-bowl.png` | `(0, 836, 268, 929)` | +| 右下织物材质 | `miniapp/assets/extracted/bottom-right-fabric.png` | `(1308, 802, 1692, 929)` | + +预览图:`docs/extracted-assets-preview.png` + +## 小程序页面映射 + +| 视觉稿页面 | 实现路径 | +| --- | --- | +| 首页 | `miniapp/pages/index/index` | +| 选择项目 | `miniapp/pages/projects/projects` | +| 选择日期与时间 | `miniapp/pages/schedule/schedule` | +| 确认预约 | `miniapp/pages/confirm/confirm` | +| 预约成功 | `miniapp/pages/success/success` | +| 我的预约承接 | `miniapp/pages/bookings/bookings` | diff --git a/docs/design-research-redesign.md b/docs/design-research-redesign.md new file mode 100644 index 0000000..20bcbeb --- /dev/null +++ b/docs/design-research-redesign.md @@ -0,0 +1,108 @@ +# 口腔预约小程序前沿设计重做依据 + +版本:v0.2 +日期:2026-06-28 + +## 1. 这次不再继续做“多版换皮” + +上一轮的问题不是颜色不够好,而是结构仍然像常见 AI 拼板:手机壳、卡片、圆角按钮、几组时间块并排。它能表达功能,但没有独特的产品语言,也没有让用户产生“这是一个值得信任的口腔护理预约体验”的感受。 + +最新重做采用一个更直接的强概念:**Smile First 高级预约体验**。 + +用户先被一个自信、健康的笑容建立情绪信任,点击 `立即预约` 后完成手机号授权,再通过“步骤 1/3、2/3、3/3”降低心理负担。页面从“授权手机号”到“选择项目”再到“选择日期与确认”,每一步只要求用户完成一个清楚动作。 + +## 2. 互联网参考结论 + +### 2.1 微信小程序设计原则 + +参考:微信小程序设计规范镜像页面 +https://sec-cdn.static.xiaomi.net/secStatic/groups/miui-sec/mukuashi/miui-wxopen-smcode/design/ + +可落地原则: + +- 每个页面只保留一个明确重点。 +- 减少与用户目标无关的视觉元素。 +- 权限请求要尊重用户知情权与操作权。 +- 小程序应轻快,路径要短,用户进入页面后要迅速知道下一步。 + +对本项目的转化: + +- 首页重点不再是品牌大海报,而是“用笑容建立信任,然后点击立即预约授权手机号”。 +- 预约页重点不再是项目卡片堆叠,而是“步骤提示 + 日期路径”。 +- 手机号授权不在页面曝光时打断用户,而是在用户主动点击 `立即预约` 时承接预约意图。 + +### 2.2 表单与转化流程 + +参考:NN/g 表单可用性建议 +https://www.nngroup.com/articles/web-form-design/ + +参考:Baymard Checkout UX 与减少表单字段 +https://baymard.com/blog/current-state-of-checkout-ux +https://baymard.com/blog/checkout-flow-average-form-fields + +可落地原则: + +- 表单要短,尽量只询问完成任务必需的信息。 +- 相关字段要视觉分组。 +- 移动端优先单列推进,不打断垂直阅读和操作节奏。 +- 账户创建或登录应延后到真正需要的节点。 + +对本项目的转化: + +- 用户只需要选择:项目、日期时段。 +- 备注默认不出现,作为确认页轻入口。 +- 手机号授权前置到首页预约入口,授权成功后才进入项目选择。 +- 确认页只保留项目、时间、已授权手机号和“确认预约”按钮,让用户自然核对。 + +### 2.3 前沿视觉语言 + +参考:Material 3 Expressive +https://m3.material.io/ +https://m3.material.io/blog/building-with-m3-expressive + +参考:Google Design 关于 expressive design 的说明 +https://design.google/library/expressive-material-design-google-research + +参考:Apple Human Interface Guidelines / Liquid Glass 材质方向 +https://developer.apple.com/design/human-interface-guidelines/materials +https://developer.apple.com/documentation/technologyoverviews/liquid-glass + +可落地原则: + +- 前沿不是“更多渐变”,而是用色彩、形状、尺寸、动效和容器关系帮助用户更快理解界面。 +- 材质感应服务于层级,不是到处玻璃拟态。 +- Expressive UI 要帮助用户达成目标,而不是只制造视觉噪音。 + +对本项目的转化: + +- 使用“暖象牙 + 深墨绿 + 松石绿 + 雾蓝灰 + 陶土红”的品牌色,不使用常见蓝绿渐变。 +- 用“笑容背景、步骤提示、统一下一步按钮”做记忆点。 +- 只在关键操作区使用材质层:背景图、已选状态、确认动作。 +- 按钮统一为“下一步”,降低判断成本。 + +## 3. 新设计原则 + +### 3.1 结构 + +- 首页:让用户先感受到自信笑容,再点击 `立即预约` 完成手机号授权。 +- 选时间:以日期路径为主轴,不让用户在卡片之间找时段。 +- 确认预约:核对项目、时间和已授权手机号,直接提交预约。 + +### 3.2 视觉 + +- 背景像牙瓷釉面,不用纯白;加入雾蓝灰和暖象牙,避免单一绿色。 +- 深墨绿承担品牌与主文字。 +- 松石绿承担主操作,陶土红只用于步骤标记。 +- 用真实笑容图、轻材质层、低对比边线构成层次。 + +### 3.3 文案 + +- 少解释功能,多告诉用户当前能做什么。 +- 避免医疗诊断、治疗承诺、焦虑营销。 +- 手机号用途写清楚:预约确认与到店提醒。 + +## 4. 产物 + +- 当前产品与交互设计:`docs/product-design.md` +- 五页交互流程文档:`docs/smile-first-5-page-interaction-flow.md` +- 当前五页视觉稿:`mockups/exports/smile-first-ai-visual-board-5-pages.png` diff --git a/docs/extracted-assets-preview.png b/docs/extracted-assets-preview.png new file mode 100644 index 0000000..cc9913b Binary files /dev/null and b/docs/extracted-assets-preview.png differ diff --git a/docs/product-design.md b/docs/product-design.md new file mode 100644 index 0000000..f17d773 --- /dev/null +++ b/docs/product-design.md @@ -0,0 +1,567 @@ +# Smile First 口腔护理预约小程序产品与交互设计文档 + +版本:v0.3 +日期:2026-06-29 +范围:微信小程序 C 端、运营后台、项目排期配置、微信手机号授权与预约提交链路 +视觉依据:[口腔预约小程序前沿设计重做依据](./design-research-redesign.md)、[Smile First 五页预约交互流程设计文档](./smile-first-5-page-interaction-flow.md) + +## 1. 产品定位 + +本小程序面向有口腔护理服务预约需求的用户,帮助用户快速完成“授权手机号、选择项目、选择日期与时间、提交预约”的闭环。体验目标是少输入、少跳转、少解释,用户在 30-60 秒内完成一次有效预约。 + +最新设计采用 **Smile First 高级预约体验**:先用真实、健康、自信的笑容建立情绪信任,再通过短路径的五页流程降低决策负担。 + +### 1.1 推荐定位 + +对外页面建议使用“口腔护理预约 / 口腔项目预约 / 护理时间预约”作为主叙事,不把首屏做成医院导诊、疾病筛查、医生问诊或治疗挂号入口。 + +如果业务主体具备医疗相关资质,可以在后台配置合规服务说明;如果资质不完整,页面文案应避免诊断、治疗承诺、处方、疗程、医生问诊等表达。 + +### 1.2 核心目标 + +- 用户点击首页预约入口并授权手机号后,可以按口腔护理项目选择前端固定生成的日期与时段。 +- 用户可以在同一页面完成日期与时间段选择。 +- 用户可以使用已授权手机号接收预约提醒并完成预约确认。 +- 用户可以在预约成功后查看预约记录。 +- 运营可以配置项目、价格文案与预约记录状态。 +- 后台可以查看预约、调整状态、备注、导出和联系用户。 + +### 1.3 设计原则 + +- **Smile First**:首屏先用自信笑容建立信任,再引导用户开始预约。 +- **五页短路径**:首页、选择项目、选择日期与时间、确认预约、预约成功。 +- **首页无底部导航**:首页只承担品牌信任和预约入口,不使用全局 Tab Bar。 +- **先授权,后选择项目**:手机号授权不在页面曝光时弹出,只在用户主动点击首页预约入口时触发;授权成功后进入项目选择。 +- **日期时间合并**:日期和时间段在同一页完成,减少一步跳转。 +- **文案克制可信**:少用医疗恐吓、焦虑营销和夸张效果承诺。 +- **视觉高级克制**:使用暖象牙、深墨绿、松石绿、雾蓝灰和陶土红,避免常见蓝绿医疗系统感。 + +## 2. 平台与合规约束 + +### 2.1 微信手机号授权 + +微信手机号能力需要用户主动同意后才能获取手机号,小程序端应使用 `button open-type="getPhoneNumber"` 触发。 + +本项目中,手机号授权前置到首页预约入口: + +- 首页曝光时不请求手机号。 +- 用户点击首页 `立即预约` 或 `预约时间` 时检查手机号授权状态。 +- 未授权时使用微信手机号授权,授权成功后进入项目选择页。 +- 已授权时直接进入项目选择页。 +- 日期与时间页不请求手机号。 +- 确认预约页只展示已绑定手机号并提交预约,换绑手机号时可再次触发授权。 + +授权成功后,后端使用微信返回的动态凭证换取手机号,并与当前微信登录会话绑定,再进入项目选择页。授权失败或用户拒绝时,停留在首页,不创建预约草稿。 + +### 2.2 位置信息与门店信息 + +本版 C 端主流程不包含独立门店选择页,也不在首页或预约路径中请求位置授权。 + +设计原因: + +- 最新视觉稿已移除门店相关信息,预约主路径聚焦项目与护理时间。 +- 小程序路径应轻快,避免用户在完成核心预约前被位置授权或门店筛选打断。 +- 若后续业务必须支持多门店,可作为二期扩展,在确认页之后或独立设置页中处理,不进入当前 MVP 主流程。 + +当前 MVP 不配置 `requiredPrivateInfos: ["getLocation"]`,也不在主流程中调用 `wx.getLocation`。 + +### 2.3 口腔行业边界 + +本文档以“口腔护理项目预约”为基础模型,避免把无资质业务包装成医疗服务。 + +可用表达: + +- 口腔护理 +- 护理项目 +- 护理时间 +- 预约提醒 +- 服务通知 +- 清新洁牙护理 +- 美白亮齿护理 + +避免表达: + +- 诊断 +- 治疗 +- 医生问诊 +- 疗效保证 +- 挂号 +- 病例 +- 术前术后 + +## 3. 用户角色 + +| 角色 | 目标 | 关键能力 | +| --- | --- | --- | +| 游客用户 | 了解品牌与预约入口 | 首页、预约入口、手机号授权说明 | +| 授权用户 | 选择项目、确认预约、查看预约记录 | 微信手机号授权、项目与时段查看、提交预约、查看/取消预约 | +| 运营人员 | 管理预约与联系用户 | 查看预约列表、修改状态、备注、导出 | +| 后台管理员 | 配置项目并查看预约记录 | 项目配置、预约管理、操作日志 | + +## 4. 功能模型 + +### 4.1 小程序端功能 + +| 模块 | 功能 | MVP 要求 | +| --- | --- | --- | +| 首页 | Smile First 主视觉、预约入口、我的预约入口 | 必须 | +| 项目选择 | 分类筛选、项目列表、项目说明、价格文案、服务时长 | 必须 | +| 日期与时间 | 日期选择、固定时间段选择 | 必须 | +| 确认预约 | 展示项目、日期、时间、手机号、备注入口、预约须知 | 必须 | +| 手机号授权 | 首页预约入口微信手机号授权、授权失败重试 | 必须 | +| 预约结果 | 成功页、预约摘要、查看我的预约、返回首页 | 必须 | +| 我的预约 | 未来预约、历史预约、状态查看、取消预约 | 建议 | +| 消息提醒 | 预约提醒订阅消息 | 后续迭代 | + +### 4.2 后台功能 + +| 模块 | 功能 | MVP 要求 | +| --- | --- | --- | +| 项目管理 | 新增/编辑/上下架项目,配置分类、服务时长、价格文案、注意事项 | 必须 | +| 排期配置 | 后台查看与维护项目排期 | 建议 | +| 预约管理 | 预约列表、筛选、状态变更、备注、导出 | 必须 | +| 用户管理 | 查看授权手机号、预约历史 | 建议 | +| 数据看板 | 预约量、转化率、项目热度、时段热度 | 后续迭代 | +| 操作日志 | 后台配置变更记录 | 建议 | + +## 5. 数据模型 + +### 5.1 核心实体 + +| 实体 | 关键字段 | 说明 | +| --- | --- | --- | +| User | id, openid, unionid, phone, createdAt, lastLoginAt | 微信用户与手机号绑定 | +| ProjectCategory | id, name, sort, status | 项目分类 | +| Project | id, categoryId, name, subtitle, priceText, status, sort | 可预约护理项目 | +| ScheduleDay | id, projectId, date, status, note | 静态预约日期 | +| TimeSlot | id, scheduleDayId, startTime, endTime, status | 静态预约时段 | +| Booking | id, userId, projectId, slotId, phone, status, remark, confirmToken, createdAt | 预约单 | +| ConfigAuditLog | id, adminId, targetType, targetId, action, before, after, createdAt | 配置审计 | + +### 5.2 预约状态 + +| 状态 | 含义 | 用户可见文案 | +| --- | --- | --- | +| pending | 已提交,待运营确认 | 待确认 | +| confirmed | 预约已确认 | 已预约 | +| cancelled_by_user | 用户取消 | 已取消 | +| cancelled_by_operator | 运营取消 | 已取消 | +| completed | 服务已完成 | 已完成 | +| expired | 超过可展示周期或运营标记失效 | 已失效 | + +### 5.3 预约提交规则 + +- 日期与时间段由前端固定生成,不依赖后端排期接口。 +- 提交预约时后端按项目、日期和开始时间确保静态时段存在。 +- 同一手机号、同一项目、同一时段默认只能有一条有效预约。 +- 用户修改项目后,应清空已选日期与时间段。 + +## 6. 核心交互流程 + +### 6.1 主预约流程 + +```mermaid +flowchart TD + A["进入首页"] --> B{"用户选择入口"} + B -- "点击立即预约 / 预约时间" --> P{"是否已授权手机号"} + B -- "点击我的预约" --> M{"是否已授权手机号"} + P -- "否" --> Q["微信手机号授权"] + P -- "是" --> C["选择项目页"] + Q -- "授权成功" --> C + Q -- "拒绝或失败" --> B + M -- "是" --> N["我的预约列表"] + M -- "否" --> O["微信手机号授权"] + O -- "授权成功" --> N + O -- "拒绝或失败" --> B + C --> D{"是否已选择项目"} + D -- "否" --> C + D -- "是,点击下一步" --> E["选择日期与时间页"] + E --> F{"是否已选择日期和时段"} + F -- "否" --> E + F -- "是,点击下一步" --> G["确认预约页"] + G --> J["提交预约"] + J -- "成功" --> K["预约成功页"] + J -- "失败" --> G + K --> L["查看我的预约 / 返回首页"] +``` + +### 6.2 手机号授权流程 + +```mermaid +flowchart TD + A["用户点击首页立即预约"] --> B{"是否已有手机号"} + B -- "有" --> C["进入选择项目页"] + B -- "无" --> D["展示授权按钮:用于预约提醒与服务通知"] + D --> E{"用户是否同意授权"} + E -- "同意" --> F["后端换取手机号并绑定用户"] + F --> C + E -- "拒绝或失败" --> G["停留首页,不创建预约草稿"] + G --> H["可重新点击立即预约发起授权"] +``` + +### 6.3 成功页返回流程 + +```mermaid +flowchart TD + A["预约成功页"] --> B{"用户操作"} + B -- "查看我的预约" --> C["我的预约列表"] + B -- "返回首页" --> D["首页"] + B -- "添加到我的日程" --> E["系统日程/提醒能力"] + C --> D +``` + +成功页应使用 `redirectTo` 或清栈策略进入,避免用户返回确认页重复提交。 + +## 7. 页面结构 + +### 7.1 首页 + +目标:用笑容建立信任,并让用户快速进入预约路径。 + +页面内容: + +- 顶部:微信状态栏、小程序胶囊、品牌名 Smile First。 +- 主视觉:真实笑容图,文案为 `自然之力 予你自信笑容`。 +- 价值说明:`温和护理 · 精致体验 · 用心陪伴`。 +- 快捷入口:`预约时间`、`我的预约`。 +- 主按钮:使用 `立即预约`。 + +交互细节: + +- 首页不使用底部 Tab Bar。 +- 视觉稿中的底部导航视为 AI 图残留,最终实现删除。 +- 首页曝光时不弹手机号授权,不弹位置授权。 +- 点击 `立即预约` 或 `预约时间`:已绑定手机号则进入项目选择页;未绑定则触发微信手机号授权,授权成功后进入项目选择页。 +- 点击 `我的预约`:已绑定手机号则进入预约列表;未绑定则触发手机号授权,授权成功后进入预约列表。 +- 用户拒绝手机号授权时停留首页,不进入项目选择页。 + +### 7.2 选择项目页 + +目标:让用户选择一个护理项目,不陷入过多说明。 + +布局建议: + +- 顶部:返回按钮、标题 `选择项目`、小程序胶囊。 +- 左侧分类:日常护理、深层清洁、美白亮齿、口气清新、儿童护理。 +- 右侧项目列表:项目名、简短说明、价格、服务时长、选中态。 +- 底部固定按钮:`下一步`。 + +项目卡片只展示必要信息: + +- 项目名称 +- 一行服务说明 +- 价格文案 +- 服务时长 +- 单选状态 + +交互规则: + +- 未选择项目时,`下一步` 置灰。 +- 选择项目后,`下一步` 启用。 +- 再次点击已选项目不取消选择。 +- 切换分类不清空已选项目。 +- 修改项目后清空已选日期与时间。 + +### 7.3 选择日期与时间页 + +目标:在同一页面完成日期和时间段选择,减少跳转。 + +布局建议: + +- 顶部:返回按钮、标题 `选择日期与时间`。 +- 已选摘要:`已选择:清新洁牙护理`。 +- 日期区域:前端固定生成未来日期和选中日期。 +- 时间段区域:展示所选日期下的固定时段。 +- 底部固定按钮:`下一步`。 + +日期状态: + +| 状态 | 表现 | 是否可点 | +| --- | --- | --- | +| 可约 | 深色日期文字 | 是 | +| 已选 | 松石绿圆形底 + 白字 | 是 | + +时段状态: + +| 状态 | 表现 | 是否可点 | +| --- | --- | --- | +| 可约 | 白底细边框,显示时间 | 是 | +| 已选 | 浅薄荷底或松石绿描边 | 是 | + +底部按钮逻辑: + +- 未选日期:禁用,文案 `请选择日期`。 +- 已选日期但未选时段:禁用,文案 `请选择时间段`。 +- 已选日期与时段:启用,文案 `下一步`。 + +### 7.4 确认预约页 + +目标:让用户核对项目、日期、时间和已授权手机号,并完成预约提交。 + +字段: + +- 服务项目 +- 预约日期 +- 预约时间 +- 服务时长 +- 价格文案 +- 手机号,显示已授权的脱敏手机号 +- 备注,可选,默认折叠或轻入口 + +按钮: + +- 主按钮:`确认预约` +- 次要入口:`更换手机号` + +说明: + +- 手机号用途文案:`用于预约提醒与服务通知`。 +- 确认页不作为首次手机号授权入口,首次授权在首页预约入口完成。 +- 换绑授权失败后停留当前页,保留项目、日期和时间。 + +编辑规则: + +| 点击位置 | 返回页面 | 保留内容 | +| --- | --- | --- | +| 服务项目编辑 | 选择项目页 | 日期和时间清空 | +| 日期或时间编辑 | 选择日期与时间页 | 保留项目 | +| 手机号换绑 | 当前页 | 保留全部预约信息 | + +### 7.5 预约成功页 + +目标:给用户确定感,并引导后续动作。 + +内容: + +- 成功状态 +- 标题:`已为你保留本次预约` +- 服务项目 +- 预约日期 +- 预约时间 +- 服务时长 +- 预约编号 +- `添加到我的日程` +- `查看我的预约` +- `返回首页` + +交互规则: + +- 点击 `查看我的预约` 进入预约列表。 +- 点击 `返回首页` 回到首页并清空当前预约草稿。 +- 成功页不允许返回确认页重复提交。 +- 后续迭代可加入订阅消息提醒,但订阅消息授权应单独触发。 + +### 7.6 我的预约页 + +目标:让用户能找回预约,减少客服成本。 + +结构: + +- 未来预约 +- 历史预约 +- 预约详情 +- 取消预约 + +取消规则由后台配置,例如距预约开始不足 2 小时不可自助取消,只能联系运营。 + +## 8. 后台配置模型 + +### 8.1 项目配置 + +字段建议: + +- 项目名称 +- 分类 +- 简短说明 +- 服务时长 +- 价格展示文案 +- 护理前提示 +- 展示排序 +- 上下架状态 + +注意:若无医疗资质,项目文案应避免“治疗某疾病、保证效果、医生诊断”等表达。 + +### 8.2 日期与时段规则 + +- 小程序前端固定生成未来日期。 +- 每个日期展示固定时段:09:30、10:30、13:00、14:00、18:00、19:00、20:00。 +- 后端不提供日期时段查询接口,也不做时段可服务状态判定。 + +### 8.3 后台预约管理 + +字段建议: + +- 预约编号 +- 项目 +- 日期 +- 开始时间 +- 手机号 +- 预约状态 + +规则: + +- 运营可以筛选、确认、取消、完成预约。 +- 取消预约不释放或改变时段状态。 + +## 9. 接口草案 + +### 9.1 小程序端接口 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/api/app/bootstrap` | 首页基础配置、推荐项目、用户预约概览 | +| GET | `/api/projects` | 项目分类与项目列表 | +| POST | `/api/auth/wechat/session` | 微信登录换取会话 | +| POST | `/api/auth/wechat/phone` | 手机号授权换取手机号 | +| POST | `/api/bookings` | 创建预约,提交项目、日期、开始时间和静态时段 ID | +| GET | `/api/bookings` | 我的预约 | +| GET | `/api/bookings/:id` | 预约详情 | +| PATCH | `/api/bookings/:id/cancel` | 取消预约 | + +`/api/bookings` 入参建议: + +```json +{ + "project_id": "project_fresh_clean", + "date": "2026-07-02", + "start_time": "09:30", + "slot_id": "slot_day_project_fresh_clean_20260702_0930", + "phone": "13900000000" +} +``` + +### 9.2 后台接口 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET/POST | `/admin/projects` | 项目管理 | +| GET/POST | `/admin/categories` | 分类管理 | +| GET/POST | `/admin/schedules` | 排期配置 | +| GET | `/admin/bookings` | 预约列表 | +| PATCH | `/admin/bookings/:id/status` | 修改预约状态 | +| GET | `/admin/reports/bookings` | 预约导出与统计 | + +## 10. 视觉设计方向 + +### 10.1 设计关键词 + +Smile First、高级克制、真实笑容、暖象牙瓷釉、轻护理感、强转化、五步短路径。界面应更像高端口腔护理品牌的小程序预约工具,而不是医院系统或强营销落地页。 + +### 10.2 色彩系统 + +| 用途 | 色值 | 说明 | +| --- | --- | --- | +| 页面背景 | `#F7F2EA` | 暖象牙瓷釉感,避免纯白刺眼 | +| 表面色 | `#FFFDF8` | 卡片、确认单、底部操作区 | +| 主文字 | `#12201E` | 最高强调文字 | +| 品牌色 | `#153D37` | 标题、品牌名、关键选中态文字 | +| 主操作 | `#0F6F63` | 主按钮、可约状态、步骤激活 | +| 辅助薄荷 | `#BFE7DE` | 选中底、轻提示背景 | +| 雾蓝灰 | `#DDE9EC` | 弱分区背景 | +| 暖强调 | `#AA573E` | 步骤标记、少量强调 | +| 次文字 | `#5A706B` | 说明文字 | +| 分割线 | `#D9E5DF` | 轻边框 | + +使用比例:暖象牙与珍珠表面 70%,深墨绿与松石绿 22%,雾蓝灰 5%,陶土红 3%。 + +### 10.3 字体与排版 + +微信小程序端优先使用系统中文字体,保证性能和一致性。通过字号、字重和留白建立高级感: + +- Hero 标题:48rpx,600。 +- 页面标题:40rpx,600。 +- 模块标题:32rpx,600。 +- 正文:28rpx,400。 +- 说明文字:24rpx,400。 +- 日期与时间数字:32rpx,600。 + +### 10.4 组件规则 + +- 主按钮使用松石绿,固定在底部安全区上方,但不遮挡内容。 +- 首页不使用底部导航。 +- 项目卡片不使用厚阴影,使用细边线和轻背景区分。 +- 日期和时段在同一页展示,选中态不能改变控件尺寸。 +- 授权按钮和普通主按钮视觉一致,文案说明放在按钮上方一行小字。 +- 空状态要给下一步动作:换项目、换日期、重试。 + +### 10.5 动效 + +- 页面进入使用轻微上移与透明度过渡。 +- 日期和时段切换只做透明度与位移变化,不做弹跳。 +- 按钮按下使用颜色层反馈,不造成布局位移。 +- 预约成功页使用一次性状态动效,之后保持静态。 +- 尊重系统“减少动态效果”设置。 + +## 11. 关键文案 + +| 场景 | 建议文案 | +| --- | --- | +| 首页主标题 | 选好项目,约好护理时间 | +| 首页情绪文案 | 自然之力,予你自信笑容 | +| 项目页标题 | 选择项目 | +| 日期时间页标题 | 选择日期与时间 | +| 手机号授权说明 | 用于预约提醒与服务通知 | +| 预约成功 | 已为你保留本次预约 | +| 取消预约 | 已取消本次预约 | + +## 12. MVP 范围 + +### 12.1 必做 + +- 首页 Smile First 主视觉与预约入口 +- 项目分类与项目列表 +- 日期与时间段合并选择 +- 首页预约入口手机号授权 +- 预约提交与成功页 +- 我的预约 +- 后台项目与预约管理 + +### 12.2 暂缓 + +- 首页底部导航 +- 独立门店选择页 +- 位置授权 +- 在线支付 +- 医生排班 +- 在线问诊 +- 会员积分 +- 复杂营销券 +- AI 客服 +- 多人同行预约 + +## 13. 验收标准 + +- 主流程只有 5 个页面:首页、选择项目、选择日期与时间、确认预约、预约成功。 +- 首页不出现底部 Tab Bar。 +- 首页点击 `立即预约` 或 `预约时间` 后先检查手机号授权。 +- 已授权手机号后才能进入选择项目页。 +- 日期和时间段在同一页面完成选择。 +- 未授权手机号不能进入项目选择、日期与时间选择路径。 +- 手机号授权由首页预约入口触发,确认预约页只展示已绑定手机号与换绑入口。 +- 用户在首页拒绝手机号授权后停留首页,不创建预约草稿。 +- 后台下架项目后,小程序不再展示该项目。 +- 预约成功后,用户能在“我的预约”看到记录。 +- 设计稿和实现稿中不出现无资质医疗承诺类文案。 + +## 14. 后续原型建议 + +第一轮原型覆盖 5 个小程序页面: + +1. 首页 +2. 选择项目 +3. 选择日期与时间 +4. 确认预约 +5. 预约成功 / 我的预约入口 + +后台原型建议覆盖 3 个页面: + +1. 项目管理 +2. 排期日历 +3. 预约列表 + +## 15. 官方能力参考 + +- 微信开放文档:手机号快速验证组件 + https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html +- 微信开放文档:小程序全局配置 + https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html diff --git a/docs/smile-first-5-page-interaction-flow.md b/docs/smile-first-5-page-interaction-flow.md new file mode 100644 index 0000000..61574fc --- /dev/null +++ b/docs/smile-first-5-page-interaction-flow.md @@ -0,0 +1,419 @@ +# Smile First 五页预约交互流程设计文档 + +版本:v0.2 +日期:2026-06-29 +视觉依据:`mockups/exports/smile-first-ai-visual-board-5-pages.png` +适用范围:微信小程序 C 端预约主流程 + +## 1. 流程定位 + +本流程基于五页视觉稿重构预约路径: + +1. 首页 +2. 选择项目 +3. 选择日期与时间 +4. 确认预约 +5. 预约成功 + +核心目标是让用户从首页点击预约主入口后先完成微信手机号授权,授权成功后再进入项目选择。已授权用户可直接进入选择项目页,确认预约页只负责核对信息与提交预约。流程不再包含独立门店选择页,也不在首页使用底部导航。 + +主体验句: + +> 选好项目,约好护理时间。 + +## 2. 首页底部导航约束 + +首页不需要底部导航。 + +视觉稿中的首页底部 Tab Bar 仅视为 AI 图残留,不进入最终交互实现。最终首页底部只能出现与当前预约路径相关的入口,例如“预约时间”“我的预约”快捷卡或一个主按钮,不能出现全局导航栏。 + +实现约束: + +- 首页不作为小程序 `tabBar` 页面配置。 +- 如果项目已有全局 `tabBar`,首页进入时需要隐藏,例如在页面 `onShow` 中调用 `wx.hideTabBar({ animation: false })`。 +- 首页底部不得出现“首页 / 服务 / 日程 / 我的”等全局 Tab。 +- “我的预约”可以作为内容区快捷卡或右上角入口存在,但不是 Tab Bar。 +- 首页主要操作使用 `立即预约`,点击后先检查手机号授权状态,授权成功后再进入 `选择项目`,不要把用户分流到多个平级栏目。 + +## 3. 主流程图 + +```mermaid +flowchart TD + A["进入首页"] --> B{"用户选择入口"} + B -- "点击立即预约 / 预约时间" --> P{"是否已授权手机号"} + B -- "点击我的预约" --> M{"是否已授权手机号"} + P -- "否" --> Q["微信手机号授权"] + P -- "是" --> C["选择项目页"] + Q -- "授权成功" --> C + Q -- "拒绝或失败" --> B + M -- "是" --> N["我的预约列表"] + M -- "否" --> O["微信手机号授权"] + O -- "授权成功" --> N + O -- "拒绝或失败" --> B + C --> D{"是否已选择项目"} + D -- "否" --> C + D -- "是,点击下一步" --> E["选择日期与时间页"] + E --> F{"是否已选择日期和时段"} + F -- "否" --> E + F -- "是,点击下一步" --> G["确认预约页"] + G --> J["提交预约"] + J -- "成功" --> K["预约成功页"] + J -- "失败" --> G + K --> L["查看我的预约 / 返回首页"] +``` + +## 4. 页面 01:首页 + +### 4.1 页面目标 + +用真实笑容和品牌气质建立信任,让用户快速理解这是一个口腔护理预约小程序,并进入预约路径。 + +### 4.2 页面结构 + +| 区域 | 内容 | 交互 | +| --- | --- | --- | +| 顶部状态区 | 微信状态栏、小程序胶囊、品牌名 Smile First | 胶囊为系统控件,无业务动作 | +| 主视觉区 | 人物笑容图、主文案 `自然之力 予你自信笑容` | 图片不点击 | +| 价值说明 | `温和护理 · 精致体验 · 用心陪伴` | 仅展示 | +| 快捷入口 | `预约时间`、`我的预约` 两个内容卡 | 分别跳转预约路径和预约列表 | +| 主按钮 | `立即预约` | 先触发手机号授权检查,授权成功后跳转选择项目页 | + +### 4.3 首页底部处理 + +最终首页删除底部 Tab Bar。若保留两个快捷卡,则它们属于页面内容,不属于固定导航。 + +建议底部结构: + +```text +[预约时间] [我的预约] + +[立即预约] +``` + +页面滚动到底部时,主按钮可以固定在底部安全区上方;如果首页内容较短,也可以作为普通按钮放在快捷卡下方。 + +### 4.4 入口规则 + +| 用户动作 | 结果 | +| --- | --- | +| 点击 `立即预约` | 已绑定手机号时进入选择项目页;未绑定时触发微信手机号授权,授权成功后进入选择项目页 | +| 点击 `预约时间` | 与 `立即预约` 一致;若已绑定且已有未完成草稿,可恢复到最近一步 | +| 点击 `我的预约` | 已绑定手机号时进入我的预约列表;未绑定时触发手机号授权,授权成功后进入预约列表 | +| 拒绝或授权失败 | 停留首页,展示 `用于预约提醒与服务通知` 说明,不创建预约草稿 | +| 直接关闭小程序 | 不产生预约草稿 | + +### 4.5 状态 + +| 状态 | 页面表现 | +| --- | --- | +| 新用户 | 显示标准首屏与预约入口 | +| 有未完成选择 | 已授权用户在快捷入口下方提示 `继续上次预约`;未授权用户先完成手机号授权 | +| 有未来预约 | `我的预约` 卡显示 `查看预约记录` | + +## 5. 页面 02:选择项目 + +### 5.1 页面目标 + +让用户选择一个口腔护理项目,并理解项目价格、时长和基础说明。 + +### 5.2 页面结构 + +| 区域 | 内容 | 交互 | +| --- | --- | --- | +| 顶部导航 | 返回按钮、标题 `选择项目`、小程序胶囊 | 返回首页 | +| 左侧分类 | 日常护理、深层清洁、美白亮齿、口气清新、儿童护理 | 点击切换项目列表 | +| 项目列表 | 项目名、简短说明、价格、时长、单选状态 | 点击项目卡选中 | +| 底部按钮 | `下一步` | 已选项目后进入选择日期与时间页 | + +### 5.3 项目卡字段 + +| 字段 | 示例 | 说明 | +| --- | --- | --- | +| 项目名称 | `清新洁牙护理` | 后台配置 | +| 项目说明 | `温和清洁 · 清新口气` | 不写诊断、治疗承诺 | +| 价格 | `¥398` | 可配置为 `到店咨询` 或价格文本 | +| 时长 | `60分钟` | 用于后续确认 | +| 选中态 | 右上角实心勾选 | 单选 | + +### 5.4 交互规则 + +- 用户首次进入时默认不选中项目,除非从首页恢复草稿。 +- 点击项目卡任意区域即可选中。 +- 再次点击已选项目不取消,保持单选稳定性。 +- 切换分类后,如果已选项目不属于当前分类,底部仍显示已选项目摘要。 +- `下一步` 在未选项目时置灰;已选项目后启用。 + +### 5.5 异常状态 + +| 场景 | 处理 | +| --- | --- | +| 项目列表为空 | 显示 `当前分类暂无可预约项目`,提供切换分类 | +| 项目下架 | 不展示;若草稿中项目已下架,提示重新选择 | +| 价格暂不可展示 | 使用 `到店咨询`,不阻断流程 | + +## 6. 页面 03:选择日期与时间 + +### 6.1 页面目标 + +把日期和时间段合并在一个页面完成,减少一步跳转。用户在同一页先选日期,再选该日期下的时段。 + +### 6.2 页面结构 + +| 区域 | 内容 | 交互 | +| --- | --- | --- | +| 顶部导航 | 返回按钮、标题 `选择日期与时间`、小程序胶囊 | 返回选择项目页 | +| 已选摘要 | `已选择:清新洁牙护理` | 点击可返回项目页修改 | +| 日期区域 | 前端固定生成未来日期和选中日期 | 点击日期加载固定时段 | +| 时段区域 | `选择时间段`,展示 09:30、10:30、13:00 等固定时段 | 点击时段选中 | +| 底部按钮 | `下一步` | 已选日期与时段后进入确认预约页 | + +### 6.3 交互顺序 + +1. 用户进入页面后,前端按所选项目生成固定日期与时段。 +2. 默认选中第一个日期。 +3. 用户点击某个日期后,刷新下方时段。 +4. 用户点击时段后,按钮启用。 +5. 点击 `下一步` 后进入确认预约页。 + +### 6.4 日期状态 + +| 状态 | 表现 | 是否可点 | +| --- | --- | --- | +| 可约 | 深色日期文字 | 是 | +| 已选 | 松石绿圆形底 + 白字 | 是 | + +### 6.5 时段状态 + +| 状态 | 表现 | 是否可点 | +| --- | --- | --- | +| 可约 | 白底细边框,显示时间 | 是 | +| 已选 | 松石绿描边或浅薄荷底 | 是 | + +### 6.6 底部按钮逻辑 + +| 条件 | 按钮状态 | +| --- | --- | +| 未选日期 | 禁用,文案 `请选择日期` | +| 已选日期但未选时段 | 禁用,文案 `请选择时间段` | +| 已选日期与时段 | 启用,文案 `下一步` | + +### 6.7 空状态 + +| 场景 | 文案 | 动作 | +| --- | --- | --- | +| 草稿缺少项目 | 返回选择项目 | 重新选择项目 | + +## 7. 页面 04:确认预约 + +### 7.1 页面目标 + +让用户核对项目、日期、时间和已授权手机号,并完成预约提交。 + +### 7.2 页面结构 + +| 区域 | 内容 | 交互 | +| --- | --- | --- | +| 顶部导航 | 返回按钮、标题 `确认预约`、小程序胶囊 | 返回日期与时间页 | +| 预约信息卡 | 服务项目、预约日期、预约时间、服务时长、价格 | 可点击 `编辑` 返回对应步骤 | +| 手机号信息卡 | 已绑定手机号、换绑入口 | 展示脱敏手机号;换绑时二次触发 `getPhoneNumber` | +| 预约须知 | 准时提醒、取消规则、联系客服 | 只展示 | +| 底部按钮 | `确认预约` | 已授权手机号后提交 | + +### 7.3 手机号规则 + +手机号首次授权前置到首页预约入口:点击 `立即预约` 或 `预约时间` 时触发授权检查,授权成功后才进入选择项目页。确认预约页默认只展示已绑定手机号,不再作为首次授权入口。 + +| 用户状态 | 页面表现 | 主按钮 | +| --- | --- | --- | +| 已授权手机号 | 显示脱敏手机号 | `确认预约` | +| 手机号失效或被清空 | 显示重新授权说明 | `返回首页授权` 或触发换绑 | +| 换绑手机号 | 保留预约信息并重新授权 | 授权成功后更新手机号 | + +实现方式: + +- 首页预约主按钮在未授权时使用 `button open-type="getPhoneNumber"`。 +- 授权成功后,后端换取手机号并绑定当前微信会话,再跳转选择项目页。 +- 确认预约页的底部主按钮为普通提交按钮,不承担首次授权。 +- 用户在首页拒绝授权时,停留首页,不进入项目选择页,也不创建预约草稿。 + +### 7.4 编辑规则 + +| 点击位置 | 返回页面 | 保留内容 | +| --- | --- | --- | +| 服务项目编辑 | 选择项目页 | 已选日期和时间清空 | +| 日期或时间编辑 | 选择日期与时间页 | 保留已选项目 | +| 手机号换绑 | 当前页 | 保留全部预约信息,授权成功后更新手机号 | + +### 7.5 提交规则 + +1. 点击确认预约。 +2. 前端检查项目、日期、时间、手机号是否完整。 +3. 调用创建预约接口。 +4. 接口成功后进入预约成功页。 +5. 接口失败时停留当前页,展示失败原因。 + +如果进入确认页时发现手机号缺失或授权状态失效,不能直接提交预约,应提示用户返回首页重新授权,或使用当前页换绑入口完成授权后再提交。 + +建议幂等: + +- 每次进入确认页生成 `confirmToken`。 +- 提交接口带上 `confirmToken`,避免重复点击生成多条预约。 +- 主按钮点击后进入 loading,接口返回前禁用。 + +## 8. 页面 05:预约成功 + +### 8.1 页面目标 + +给用户明确完成感,并提供预约记录、日程添加和返回首页入口。 + +### 8.2 页面结构 + +| 区域 | 内容 | 交互 | +| --- | --- | --- | +| 成功状态 | 勾选图标、标题 `已为你保留本次预约` | 静态展示 | +| 说明文案 | `期待为你带来美好的护理体验` | 静态展示 | +| 预约信息卡 | 服务项目、预约日期、预约时间、服务时长、预约编号 | 静态展示 | +| 辅助按钮 | `添加到我的日程` | 调起日历或提醒能力 | +| 主按钮 | `查看我的预约` | 进入预约记录 | +| 次按钮 | `返回首页` | 回到首页 | + +### 8.3 成功后动作 + +| 用户动作 | 结果 | +| --- | --- | +| 点击 `查看我的预约` | 进入我的预约列表 | +| 点击 `返回首页` | 返回首页,清空当前预约草稿 | +| 点击 `添加到我的日程` | 调起系统日历或小程序提醒能力 | +| 直接关闭小程序 | 预约已保存 | + +### 8.4 失败回退 + +若创建预约成功但成功页数据加载失败: + +- 展示基础成功态。 +- 提示 `预约已提交,可在我的预约中查看`。 +- 主按钮仍为 `查看我的预约`。 + +## 9. 页面跳转与返回规则 + +| 当前页 | 返回按钮行为 | 手势返回行为 | +| --- | --- | --- | +| 首页 | 退出小程序或返回来源页 | 同系统默认 | +| 选择项目 | 返回首页 | 保留或清空草稿由业务配置 | +| 选择日期与时间 | 返回选择项目 | 保留已选项目 | +| 确认预约 | 返回选择日期与时间 | 保留项目、日期、时间 | +| 预约成功 | 返回首页,不回到确认页 | 禁止返回重复提交状态 | + +成功页需要使用重定向或清栈策略,避免用户从成功页返回确认页后重复提交。 + +建议: + +- 从确认页到成功页使用 `wx.redirectTo`。 +- 从成功页返回首页使用 `wx.reLaunch` 或清空预约草稿后 `wx.navigateBack` 到首页。 + +## 10. 预约草稿状态 + +前端维护一个轻量预约草稿: + +| 字段 | 来源 | 清空时机 | +| --- | --- | --- | +| `projectId` | 选择项目页 | 修改项目或预约成功 | +| `projectName` | 选择项目页 | 修改项目或预约成功 | +| `date` | 选择日期与时间页 | 修改项目或预约成功 | +| `timeSlotId` | 选择日期与时间页 | 修改项目、修改日期或预约成功 | +| `timeText` | 选择日期与时间页 | 修改项目、修改日期或预约成功 | +| `phone` | 首页预约入口手机号授权 | 用户换绑或退出账号 | +| `confirmToken` | 确认预约页 | 提交成功或确认页销毁 | + +草稿保存建议: + +- 当前会话内使用页面状态或全局 store。 +- 可选使用本地缓存保存未完成草稿,但过期时间不超过 30 分钟。 +- 日期与时间由前端固定生成,不依赖后端排期校验。 + +## 11. 接口建议 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/api/app/bootstrap` | 首页文案、推荐项目、用户预约概览 | +| GET | `/api/projects` | 获取项目分类和项目列表 | +| POST | `/api/auth/wechat/session` | 微信登录换取会话 | +| POST | `/api/auth/wechat/phone` | 手机号授权换取手机号 | +| POST | `/api/bookings` | 创建预约,提交项目、日期、开始时间和静态时段 ID | +| GET | `/api/bookings` | 我的预约列表 | +| GET | `/api/bookings/:id` | 预约详情 | + +`/api/bookings` 入参建议: + +```json +{ + "project_id": "project_fresh_clean", + "date": "2026-07-02", + "start_time": "09:30", + "slot_id": "slot_day_project_fresh_clean_20260702_0930", + "phone": "13900000000" +} +``` + +## 12. 埋点建议 + +| 事件 | 触发点 | 关键属性 | +| --- | --- | --- | +| `home_view` | 首页曝光 | source | +| `home_start_booking_click` | 点击预约入口 | entry | +| `project_select` | 选择项目 | projectId, category | +| `project_next_click` | 项目页下一步 | projectId | +| `date_select` | 选择日期 | projectId, date | +| `slot_select` | 选择时段 | projectId, date, slotId | +| `confirm_view` | 确认页曝光 | projectId, date, slotId | +| `phone_auth_start` | 点击手机号授权 | entry, projectId 可选 | +| `phone_auth_success` | 手机号授权成功 | entry, projectId 可选 | +| `phone_auth_fail` | 手机号授权失败 | reason | +| `booking_submit_success` | 预约成功 | bookingId, projectId | +| `booking_submit_fail` | 预约失败 | reason | + +## 13. 合规与文案边界 + +本流程使用“口腔护理预约”叙事,不使用医疗诊疗叙事。 + +推荐文案: + +- `预约护理时间` +- `选择日期与时间` +- `授权手机号用于预约提醒` +- `已为你保留本次预约` +- `查看我的预约` + +避免文案: + +- `诊断` +- `治疗` +- `医生问诊` +- `疗效保证` +- `病历` +- `到院` +- `挂号` + +手机号授权说明: + +> 用于预约提醒与服务通知。 + +不要写成: + +- `必须授权手机号` +- `不授权无法继续使用` +- `登录后才能查看` + +## 14. 验收标准 + +- 主流程只有 5 个页面:首页、选择项目、选择日期与时间、确认预约、预约成功。 +- 首页不出现底部 Tab Bar。 +- 首页点击 `立即预约` 或 `预约时间` 后先检查手机号授权。 +- 已授权手机号后才能进入选择项目页。 +- 日期和时间段在同一页面完成选择。 +- 未选择项目时不能进入日期与时间页。 +- 未选择日期或时间段时不能进入确认预约页。 +- 确认预约页不作为首次手机号授权入口,只展示已绑定手机号与换绑入口。 +- 用户在首页拒绝手机号授权后停留首页,不进入项目选择页。 +- 预约成功后不能通过返回键回到确认页重复提交。 +- 页面文案不出现医疗诊断、治疗承诺、医生问诊等高风险表达。 diff --git a/docs/smile-first-ai-visual-board-5-pages.png b/docs/smile-first-ai-visual-board-5-pages.png new file mode 100644 index 0000000..0e2b9e5 Binary files /dev/null and b/docs/smile-first-ai-visual-board-5-pages.png differ diff --git a/uniapp/App.uvue b/uniapp/App.uvue new file mode 100644 index 0000000..5c1f0af --- /dev/null +++ b/uniapp/App.uvue @@ -0,0 +1,316 @@ + + + diff --git a/uniapp/index.html b/uniapp/index.html new file mode 100644 index 0000000..a429bcb --- /dev/null +++ b/uniapp/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/uniapp/main.uts b/uniapp/main.uts new file mode 100644 index 0000000..07ed640 --- /dev/null +++ b/uniapp/main.uts @@ -0,0 +1,9 @@ +import App from './App.uvue' + +import { createSSRApp } from 'vue' +export function createApp() { + const app = createSSRApp(App) + return { + app + } +} \ No newline at end of file diff --git a/uniapp/manifest.json b/uniapp/manifest.json new file mode 100644 index 0000000..45307cd --- /dev/null +++ b/uniapp/manifest.json @@ -0,0 +1,65 @@ +{ + "name" : "骨安通", + "appid" : "__UNI__B412B92", + "description" : "", + "versionName" : "1.0.2", + "versionCode" : "100", + "uni-app-x" : {}, + /* 快应用特有相关 */ + "quickapp" : {}, + /* 小程序特有相关 */ + "mp-weixin" : { + "appid" : "wx0a0e45ada30f01d4", + "setting" : { + "urlCheck" : false, + "minified" : true + }, + "usingComponents" : true + }, + "mp-alipay" : { + "usingComponents" : true + }, + "mp-baidu" : { + "usingComponents" : true + }, + "mp-toutiao" : { + "usingComponents" : true + }, + "uniStatistics" : { + "enable" : false + }, + "vueVersion" : "3", + "app" : { + "distribute" : { + "icons" : { + "android" : { + "hdpi" : "", + "xhdpi" : "", + "xxhdpi" : "", + "xxxhdpi" : "" + } + } + } + }, + "app-android" : { + "distribute" : { + "modules" : {}, + "icons" : { + "hdpi" : "", + "xhdpi" : "", + "xxhdpi" : "", + "xxxhdpi" : "" + }, + "splashScreens" : { + "default" : {} + } + } + }, + "app-ios" : { + "distribute" : { + "modules" : {}, + "icons" : {}, + "splashScreens" : {} + } + } +} diff --git a/uniapp/pages.json b/uniapp/pages.json new file mode 100644 index 0000000..7493085 --- /dev/null +++ b/uniapp/pages.json @@ -0,0 +1,20 @@ +{ + "pages": [ + { + "path": "pages/price-query/price-query", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "全国口腔价格查询" + } + } + ], + "globalStyle": { + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "navigationBarTitleText": "骨安通", + "navigationBarBackgroundColor": "#FFFDF8", + "backgroundColor": "#F7F2EA", + "backgroundTextStyle": "dark" + }, + "uniIdRouter": {} +} diff --git a/uniapp/pages/price-query/price-query.uvue b/uniapp/pages/price-query/price-query.uvue new file mode 100644 index 0000000..7f742c4 --- /dev/null +++ b/uniapp/pages/price-query/price-query.uvue @@ -0,0 +1,664 @@ + + + + + diff --git a/uniapp/static/icons/cloud-storage-upload.png b/uniapp/static/icons/cloud-storage-upload.png new file mode 100644 index 0000000..71b6a2d Binary files /dev/null and b/uniapp/static/icons/cloud-storage-upload.png differ diff --git a/uniapp/static/icons/dg.png b/uniapp/static/icons/dg.png new file mode 100644 index 0000000..112db30 Binary files /dev/null and b/uniapp/static/icons/dg.png differ diff --git a/uniapp/static/icons/fa-users.png b/uniapp/static/icons/fa-users.png new file mode 100644 index 0000000..eb955b3 Binary files /dev/null and b/uniapp/static/icons/fa-users.png differ diff --git a/uniapp/static/icons/hospital-user.png b/uniapp/static/icons/hospital-user.png new file mode 100644 index 0000000..9a687b5 Binary files /dev/null and b/uniapp/static/icons/hospital-user.png differ diff --git a/uniapp/static/icons/ico_book.svg b/uniapp/static/icons/ico_book.svg new file mode 100644 index 0000000..cdd2ee1 --- /dev/null +++ b/uniapp/static/icons/ico_book.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/uniapp/static/icons/ico_orders.svg b/uniapp/static/icons/ico_orders.svg new file mode 100644 index 0000000..cc879d6 --- /dev/null +++ b/uniapp/static/icons/ico_orders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/uniapp/static/images/bg.png b/uniapp/static/images/bg.png new file mode 100644 index 0000000..0e5a95e Binary files /dev/null and b/uniapp/static/images/bg.png differ diff --git a/uniapp/static/images/project-placeholder.svg b/uniapp/static/images/project-placeholder.svg new file mode 100644 index 0000000..14dfb31 --- /dev/null +++ b/uniapp/static/images/project-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/uniapp/static/images/share-card.png b/uniapp/static/images/share-card.png new file mode 100644 index 0000000..0f223bd Binary files /dev/null and b/uniapp/static/images/share-card.png differ diff --git a/uniapp/static/images/updateindexbg.png b/uniapp/static/images/updateindexbg.png new file mode 100644 index 0000000..1b02072 Binary files /dev/null and b/uniapp/static/images/updateindexbg.png differ diff --git a/uniapp/uni.scss b/uniapp/uni.scss new file mode 100644 index 0000000..62eb87b --- /dev/null +++ b/uniapp/uni.scss @@ -0,0 +1,76 @@ +/** + * 这里是uni-app内置的常用样式变量 + * + * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量 + * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App + * + */ + +/** + * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能 + * + * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件 + */ + +/* 颜色变量 */ + +/* 行为相关颜色 */ +$uni-color-primary: #007aff; +$uni-color-success: #4cd964; +$uni-color-warning: #f0ad4e; +$uni-color-error: #dd524d; + +/* 文字基本颜色 */ +$uni-text-color:#333;//基本色 +$uni-text-color-inverse:#fff;//反色 +$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息 +$uni-text-color-placeholder: #808080; +$uni-text-color-disable:#c0c0c0; + +/* 背景颜色 */ +$uni-bg-color:#ffffff; +$uni-bg-color-grey:#f8f8f8; +$uni-bg-color-hover:#f1f1f1;//点击状态颜色 +$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色 + +/* 边框颜色 */ +$uni-border-color:#c8c7cc; + +/* 尺寸变量 */ + +/* 文字尺寸 */ +$uni-font-size-sm:12px; +$uni-font-size-base:14px; +$uni-font-size-lg:16px; + +/* 图片尺寸 */ +$uni-img-size-sm:20px; +$uni-img-size-base:26px; +$uni-img-size-lg:40px; + +/* Border Radius */ +$uni-border-radius-sm: 2px; +$uni-border-radius-base: 3px; +$uni-border-radius-lg: 6px; +$uni-border-radius-circle: 50%; + +/* 水平间距 */ +$uni-spacing-row-sm: 5px; +$uni-spacing-row-base: 10px; +$uni-spacing-row-lg: 15px; + +/* 垂直间距 */ +$uni-spacing-col-sm: 4px; +$uni-spacing-col-base: 8px; +$uni-spacing-col-lg: 12px; + +/* 透明度 */ +$uni-opacity-disabled: 0.3; // 组件禁用态的透明度 + +/* 文章场景相关 */ +$uni-color-title: #2C405A; // 文章标题颜色 +$uni-font-size-title:20px; +$uni-color-subtitle: #555555; // 二级标题颜色 +$uni-font-size-subtitle:26px; +$uni-color-paragraph: #3F536E; // 文章段落颜色 +$uni-font-size-paragraph:15px; diff --git a/uniapp/utils/api.uts b/uniapp/utils/api.uts new file mode 100644 index 0000000..0b67123 --- /dev/null +++ b/uniapp/utils/api.uts @@ -0,0 +1,73 @@ +const LOCAL_API_BASE = 'http://localhost:9800' +const PROD_API_BASE = 'https://app01.min.wooo.host' + +export function apiBase(): string { + if (isLocalMiniProgramDev()) return LOCAL_API_BASE + return PROD_API_BASE +} + +function isLocalMiniProgramDev(): boolean { + // #ifdef MP-WEIXIN + try { + const accountInfo = uni.getAccountInfoSync() + const info = accountInfo as any + if (info.miniProgram == null) return false + const miniProgram = info.miniProgram as any + if (miniProgram.envVersion == null) return false + const envVersion = miniProgram.envVersion as string + return envVersion == 'develop' + } catch (e) { + return false + } + // #endif + return false +} + +function errorMessage(data: any): string { + if (data == null) return 'request failed' + const obj = data as any + if (obj.error != null) return obj.error as string + return 'request failed' +} + +export function request(method: RequestMethod, path: string, data: any | null = null): Promise { + return new Promise((resolve, reject) => { + uni.request({ + url: apiBase() + path, + method, + data, + header: { + 'content-type': 'application/json' + }, + success: (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(res.data) + return + } + reject(new Error(errorMessage(res.data))) + }, + fail: (err) => { + reject(new Error(err.errMsg)) + } + }) + }) +} + +export function get(path: string): Promise { + return request('GET' as RequestMethod, path, null) +} + +export function post(path: string, data: any): Promise { + return request('POST' as RequestMethod, path, data) +} + +export function patch(path: string, data: any): Promise { + return request('PATCH' as RequestMethod, path, data) +} + +export function mediaUrl(value: string | null): string { + if (value == null || value.length == 0) return '' + if (value.indexOf('http://') == 0 || value.indexOf('https://') == 0) return value + if (value.indexOf('/') == 0) return apiBase() + value + return value +} diff --git a/uniapp/utils/fallback.uts b/uniapp/utils/fallback.uts new file mode 100644 index 0000000..310045e --- /dev/null +++ b/uniapp/utils/fallback.uts @@ -0,0 +1,93 @@ +import { Category, Day, Project, Slot } from './types.uts' +import { formatDate } from './format.uts' +import { PROJECT_PLACEHOLDER_IMAGE } from './theme.uts' +import { mediaUrl } from './api.uts' + +export function decorateCategories(categories: Category[]): Category[] { + const iconMap = ['heart', 'search', 'smile', 'service', 'user'] + const imageKeys = ['daily', 'clean', 'white', 'breath', 'child'] + const result: Category[] = [] + for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex += 1) { + const category = categories[categoryIndex] + const rawImageKey = category.image_key == null ? '' : category.image_key as string + const imageKey = rawImageKey.length > 0 ? rawImageKey : imageKeys[categoryIndex % imageKeys.length] + const projects: Project[] = [] + for (let projectIndex = 0; projectIndex < category.projects.length; projectIndex += 1) { + const project = category.projects[projectIndex] + const rawProjectImageKey = project.image_key == null ? '' : project.image_key as string + const rawProjectImage = project.image == null ? '' : project.image as string + const projectImageKey = rawProjectImageKey.length > 0 ? rawProjectImageKey : imageKey + const projectImage = rawProjectImage.length > 0 ? mediaUrl(rawProjectImage) : '' + projects.push({ + id: project.id, + category_id: project.category_id, + name: project.name, + subtitle: project.subtitle, + price_text: project.price_text, + image: projectImage.length > 0 ? projectImage : PROJECT_PLACEHOLDER_IMAGE, + image_key: projectImageKey, + sortIndex: projectIndex + 1 + } as Project) + } + const rawIcon = category.icon == null ? '' : category.icon as string + result.push({ + id: category.id, + name: category.name, + icon: rawIcon.length > 0 ? rawIcon : iconMap[categoryIndex % iconMap.length], + image_key: imageKey, + projects + } as Category) + } + return result +} + +export function fallbackCategories(): Category[] { + return [ + { id: 'cat_meniscus_injury', name: '半月板损伤', image_key: 'daily', projects: [] } as Category, + { id: 'cat_synovitis', name: '滑膜炎', image_key: 'clean', projects: [] } as Category, + { id: 'cat_knee_arthritis', name: '膝关节炎', image_key: 'white', projects: [] } as Category, + { id: 'cat_lumbar_disc_herniation', name: '腰椎间盘突出', image_key: 'breath', projects: [] } as Category, + { id: 'cat_lumbar_muscle_strain', name: '腰肌劳损', image_key: 'child', projects: [] } as Category, + { id: 'cat_lumbar_spinal_stenosis', name: '腰椎管狭窄', image_key: 'daily', projects: [] } as Category, + { id: 'cat_cervical_spondylosis', name: '颈椎病', image_key: 'clean', projects: [] } as Category, + { id: 'cat_lumbar_spondylosis', name: '腰椎病', image_key: 'white', projects: [] } as Category + ] +} + +export function fallbackDays(projectId: string): Day[] { + const starts = ['09:30', '10:30', '13:00', '14:00', '18:00', '19:00', '20:00'] + const base = new Date() + const days: Day[] = [] + for (let i = 1; i <= 21; i += 1) { + const date = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i) + const dateText = formatDate(date) + const dayId = scheduleDayId(projectId, dateText) + const slots: Slot[] = [] + for (let idx = 0; idx < starts.length; idx += 1) { + const start = starts[idx] + slots.push({ + id: slotId(dayId, start), + start_time: start, + status: 'available' + } as Slot) + } + days.push({ + date: dateText, + status: 'available', + slots + } as Day) + } + return days +} + +function scheduleDayId(projectId: string, dateText: string): string { + return 'day_' + projectId + '_' + compactDate(dateText) +} + +function slotId(dayId: string, startTime: string): string { + return 'slot_' + dayId + '_' + startTime.replace(':', '') +} + +function compactDate(dateText: string): string { + return dateText.split('-').join('') +} diff --git a/uniapp/utils/format.uts b/uniapp/utils/format.uts new file mode 100644 index 0000000..5d2e367 --- /dev/null +++ b/uniapp/utils/format.uts @@ -0,0 +1,84 @@ +import { BookingDetail, Day, Slot } from './types.uts' + +export function formatDateText(date: string): string { + if (date.length == 0) return '' + const parts = date.split('-') + if (parts.length < 3) return date + return parseInt(parts[1]).toString() + '月' + parseInt(parts[2]).toString() + '日' +} + +export function maskPhone(phone: string): string { + if (phone.length < 7) return phone + return phone.slice(0, 3) + ' **** ' + phone.slice(phone.length - 4) +} + +export function pad(value: number): string { + if (value < 10) return '0' + value.toString() + return value.toString() +} + +export function formatMonth(date: Date): string { + return date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) +} + +export function formatDate(date: Date): string { + return date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +} + +export function timestampFromDate(dateText: string): number { + if (dateText.length == 0) return 0 + const parts = dateText.split('-') + if (parts.length < 3) return 0 + return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])).getTime() +} + +export function normalizeSlots(slots: Slot[]): Slot[] { + const result: Slot[] = [] + for (let i = 0; i < slots.length; i += 1) { + const slot = slots[i] + result.push({ + id: slot.id, + start_time: slot.start_time, + status: 'available', + statusText: '可约' + } as Slot) + } + return result +} + +export function bookingStatusText(status: string): string { + if (status == 'pending') return '待确认' + if (status == 'confirmed') return '已预约' + if (status == 'cancelled_by_user') return '已取消' + if (status == 'cancelled_by_operator') return '已取消' + if (status == 'completed') return '已完成' + if (status == 'expired') return '已失效' + return status +} + +export function normalizeBookings(bookings: BookingDetail[]): BookingDetail[] { + const result: BookingDetail[] = [] + for (let i = 0; i < bookings.length; i += 1) { + const item = bookings[i] + const status = item.booking.status + result.push({ + booking: item.booking, + project: item.project, + day: item.day, + slot: item.slot, + key: item.booking.id, + dateText: formatDateText(item.day.date), + statusText: bookingStatusText(status), + canCancel: status == 'pending' || status == 'confirmed' + } as BookingDetail) + } + return result +} + +export function emptyDay(): Day { + return { date: '', status: '', slots: [] } as Day +} + +export function emptySlot(): Slot { + return { id: '', start_time: '', status: '' } as Slot +} diff --git a/uniapp/utils/store.uts b/uniapp/utils/store.uts new file mode 100644 index 0000000..bd670ec --- /dev/null +++ b/uniapp/utils/store.uts @@ -0,0 +1,101 @@ +import { BookingDetail, Day, Draft, Project, Slot } from './types.uts' + +const DRAFT_KEY = 'sf_booking_draft' +const LAST_BOOKING_KEY = 'sf_last_booking' + +export function emptyDraft(): Draft { + return { + project: null, + day: null, + date: '', + slot: null, + phone: '' + } as Draft +} + +export function getDraft(): Draft { + const raw = uni.getStorageSync(DRAFT_KEY) + if (raw == null || raw == '') return emptyDraft() + const draft = raw as Draft + if (draft.phone == null) draft.phone = '' + if (draft.date == null) draft.date = '' + return draft +} + +export function saveDraft(draft: Draft): Draft { + uni.setStorageSync(DRAFT_KEY, draft) + return draft +} + +export function clearDraft(): void { + const phone = getDraft().phone + uni.removeStorageSync(DRAFT_KEY) + if (phone.length > 0) { + saveDraft({ + project: null, + day: null, + date: '', + slot: null, + phone + } as Draft) + } +} + +export function setProject(project: Project): Draft { + const draft = getDraft() + const next = { + project: { + id: project.id, + category_id: project.category_id, + name: project.name, + subtitle: project.subtitle, + price_text: project.price_text, + image: project.image, + image_key: project.image_key, + sortIndex: project.sortIndex + } as Project, + day: null, + date: '', + slot: null, + phone: draft.phone + } as Draft + return saveDraft(next) +} + +export function setSchedule(day: Day, slot: Slot): Draft { + const draft = getDraft() + const nextDay = { + date: day.date, + status: day.status, + slots: [] + } as Day + const nextSlot = { + id: slot.id, + start_time: slot.start_time, + status: slot.status + } as Slot + const next = { + project: draft.project, + day: nextDay, + date: nextDay.date, + slot: nextSlot, + phone: draft.phone + } as Draft + return saveDraft(next) +} + +export function setPhone(phone: string): Draft { + const draft = getDraft() + draft.phone = phone + return saveDraft(draft) +} + +export function setLastBooking(detail: BookingDetail): void { + uni.setStorageSync(LAST_BOOKING_KEY, detail) +} + +export function getLastBooking(): BookingDetail | null { + const raw = uni.getStorageSync(LAST_BOOKING_KEY) + if (raw == null || raw == '') return null + return raw as BookingDetail +} diff --git a/uniapp/utils/theme.uts b/uniapp/utils/theme.uts new file mode 100644 index 0000000..7c62b18 --- /dev/null +++ b/uniapp/utils/theme.uts @@ -0,0 +1,11 @@ +import { QuickAction } from './types.uts' + +export const PROJECT_PLACEHOLDER_IMAGE = '/static/images/project-placeholder.svg' + +export function quickActions(): QuickAction[] { + return [ + { key: 'booking', icon: '/static/icons/ico_book.svg', title: '预约时间', subtitle: '选项目 约时间' }, + { key: 'orders', icon: '/static/icons/ico_orders.svg', title: '我的预约', subtitle: '查看预约记录' }, + { key: 'price-query', icon: '/static/icons/ico_book.svg', title: '价格查询', subtitle: '查看项目参考价' } + ] +} diff --git a/uniapp/utils/types.uts b/uniapp/utils/types.uts new file mode 100644 index 0000000..c48df2f --- /dev/null +++ b/uniapp/utils/types.uts @@ -0,0 +1,68 @@ +export type User = { + id: string + openid: string + phone: any | null +} + +export type Project = { + id: string + category_id: string + name: string + subtitle: string + price_text: string + image?: string + image_key?: string + sortIndex?: number +} + +export type Category = { + id: string + name: string + icon?: string + image_key?: string + projects: Project[] +} + +export type Day = { + date: string + status?: string + slots: Slot[] +} + +export type Slot = { + id: string + start_time: string + status: string + statusText?: string +} + +export type Draft = { + project: Project | null + day: Day | null + date: string + slot: Slot | null + phone: string +} + +export type Booking = { + id: string + status: string +} + +export type BookingDetail = { + booking: Booking + project: Project + day: Day + slot: Slot + key?: string + dateText?: string + statusText?: string + canCancel?: boolean +} + +export type QuickAction = { + key: string + title: string + subtitle: string + icon: string +} diff --git a/uniapp/utils/user.uts b/uniapp/utils/user.uts new file mode 100644 index 0000000..8e60367 --- /dev/null +++ b/uniapp/utils/user.uts @@ -0,0 +1,197 @@ +import { apiBase, post } from './api.uts' +import { User } from './types.uts' + +const USER_KEY = 'sf_user' + +export function demoUser(): User { + const openid = generateFakeOpenID() + return { + id: openid, + openid, + phone: null + } as User +} + +export function currentUser(): User | null { + const raw = uni.getStorageSync(USER_KEY) + if (raw == null || raw == '') return null + return raw as User +} + +export function phoneFromUser(user: any | null): string { + if (user == null) return '' + const phone = (user as any).phone + if (phone == null) return '' + if (typeof phone == 'string') return phone as string + const obj = phone as any + if (obj.Valid == true || obj.valid == true) { + if (obj.String != null) return obj.String as string + if (obj.string != null) return obj.string as string + } + return '' +} + +export function openidFromUser(user: any | null): string { + if (user == null) return fallbackOpenID() + const obj = user as any + if (obj.openid != null) { + const openid = obj.openid as string + if (openid.length > 0) return openid + } + if (obj.Openid != null) { + const openid = obj.Openid as string + if (openid.length > 0) return openid + } + return fallbackOpenID() +} + +export function setUser(user: User): void { + uni.setStorageSync(USER_KEY, user) +} + +export function bindPhoneFromEvent(event: UniEvent): Promise { + const detail = (event as any).detail as any + const errMsg = detail != null && detail.errMsg != null ? detail.errMsg as string : '' + console.log('[phone-auth] getPhoneNumber event', phoneAuthDetailLog(detail)) + if (errMsg.indexOf('ok') < 0) { + console.log('[phone-auth] getPhoneNumber rejected', { errMsg }) + return Promise.reject(new Error('手机号授权未完成')) + } + const user = currentUser() + const phoneCode = detail != null && detail.code != null ? detail.code as string : '' + if (phoneCode.length == 0) { + console.log('[phone-auth] getPhoneNumber missing code', phoneAuthDetailLog(detail)) + return Promise.reject(new Error('没有拿到手机号授权 code')) + } + const openid = openidFromUser(user) + console.log('[phone-auth] backend bind start', { + openid: maskIdentifier(openid), + hasStoredPhone: phoneFromUser(user).length > 0, + phoneCodeLength: phoneCode.length, + envVersion: miniProgramEnvVersion(), + apiBase: apiBase() + }) + return post('/api/auth/wechat/phone', { + openid, + phoneCode + }).then((res) => { + const nextUser = (res as any).user as User + const phone = phoneFromUser(nextUser) + if (phone.length == 0) { + console.log('[phone-auth] backend bind empty phone', { + openid: maskIdentifier(openidFromUser(nextUser)) + }) + return Promise.reject(new Error('后端未返回已绑定手机号')) + } + setUser(nextUser) + console.log('[phone-auth] backend bind success', { + openid: maskIdentifier(openidFromUser(nextUser)), + phone: maskPhoneForLog(phone) + }) + return { + user: nextUser, + phone + } + }).catch((err) => { + const message = err != null && (err as any).message != null ? (err as any).message as string : '手机号绑定失败' + console.log('[phone-auth] backend bind failed', { + openid: maskIdentifier(openid), + message + }) + return Promise.reject(err) + }) +} + +function phoneAuthDetailLog(detail: any | null): any { + if (detail == null) { + return { + errMsg: '', + hasCode: false, + codeLength: 0, + hasEncryptedData: false, + hasIv: false, + hasCloudID: false + } + } + const code = detail.code != null ? detail.code as string : '' + const encryptedData = detail.encryptedData != null ? detail.encryptedData as string : '' + const iv = detail.iv != null ? detail.iv as string : '' + const cloudID = detail.cloudID != null ? detail.cloudID as string : '' + return { + errMsg: detail.errMsg != null ? detail.errMsg as string : '', + hasCode: code.length > 0, + codeLength: code.length, + hasEncryptedData: encryptedData.length > 0, + hasIv: iv.length > 0, + hasCloudID: cloudID.length > 0 + } +} + +function maskIdentifier(value: string): string { + if (value.length <= 8) return value + return value.slice(0, 4) + '...' + value.slice(value.length - 4) +} + +function maskPhoneForLog(phone: string): string { + if (phone.length < 7) return phone + return phone.slice(0, 3) + '****' + phone.slice(phone.length - 4) +} + +function fallbackOpenID(): string { + const stored = currentUser() + if (stored != null) { + const obj = stored as any + if (obj.openid != null) { + const openid = obj.openid as string + if (openid.length > 0) return openid + } + if (obj.Openid != null) { + const openid = obj.Openid as string + if (openid.length > 0) return openid + } + } + const user = demoUser() + setUser(user) + return user.openid +} + +function generateFakeOpenID(): string { + return 'fake_user_' + compactDateTime(new Date()) + '_' + randomFourDigits() +} + +function compactDateTime(date: Date): string { + return date.getFullYear().toString() + + padNumber(date.getMonth() + 1, 2) + + padNumber(date.getDate(), 2) + + padNumber(date.getHours(), 2) + + padNumber(date.getMinutes(), 2) + + padNumber(date.getSeconds(), 2) +} + +function randomFourDigits(): string { + return padNumber(Math.floor(Math.random() * 10000), 4) +} + +function padNumber(value: number, size: number): string { + let text = value.toString() + while (text.length < size) { + text = '0' + text + } + return text +} + +function miniProgramEnvVersion(): string { + // #ifdef MP-WEIXIN + try { + const accountInfo = uni.getAccountInfoSync() + const info = accountInfo as any + if (info.miniProgram == null) return '' + const miniProgram = info.miniProgram as any + if (miniProgram.envVersion == null) return '' + return miniProgram.envVersion as string + } catch (e) { + return '' + } + // #endif + return '' +}