From 7e41bcaaee8f88e4c8882b406b9f9d3418c48baf Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 23 Sep 2026 21:26:58 +0800 Subject: [PATCH] chore: remove legacy booking flows and use local admin auth --- .env.example | 12 +- README.md | 73 +-- backend/Dockerfile | 1 - backend/cmd/smilefirst/main.go | 28 +- backend/cmd/smilefirst/main_test.go | 4 - backend/go.mod | 3 - backend/go.sum | 6 - backend/internal/config/config.go | 108 +--- backend/internal/config/config_test.go | 71 +-- backend/internal/db/app_config.sql.go | 43 -- backend/internal/db/audit_logs.sql.go | 52 -- backend/internal/db/bookings.sql.go | 282 --------- backend/internal/db/categories.sql.go | 167 ----- backend/internal/db/models.go | 66 -- backend/internal/db/projects.sql.go | 272 --------- backend/internal/db/schedules.sql.go | 288 --------- backend/internal/db/users.sql.go | 77 --- backend/internal/httpapi/admin.go | 299 ++------- backend/internal/httpapi/admin_test.go | 215 ++++--- backend/internal/httpapi/httpapi.go | 283 +-------- backend/internal/httpapi/templates/base.html | 1 - backend/internal/httpapi/templates/login.html | 17 +- backend/internal/service/admin.go | 113 ---- backend/internal/service/phone_test.go | 11 +- backend/internal/service/schema_test.go | 26 +- backend/internal/service/service.go | 570 +----------------- backend/internal/service/views.go | 83 --- backend/migrations/schema.sql | 81 +-- backend/queries/app_config.sql | 11 - backend/queries/audit_logs.sql | 7 - backend/queries/bookings.sql | 40 -- backend/queries/categories.sql | 26 - backend/queries/projects.sql | 45 -- backend/queries/schedules.sql | 53 -- backend/queries/users.sql | 15 - docker-compose.yml | 11 +- uniapp/pages/price-query/price-query.uvue | 5 +- uniapp/utils/api.uts | 15 - uniapp/utils/fallback.uts | 93 --- uniapp/utils/format.uts | 84 --- uniapp/utils/store.uts | 101 ---- uniapp/utils/theme.uts | 11 - uniapp/utils/types.uts | 63 -- 43 files changed, 285 insertions(+), 3547 deletions(-) delete mode 100644 backend/internal/db/app_config.sql.go delete mode 100644 backend/internal/db/audit_logs.sql.go delete mode 100644 backend/internal/db/bookings.sql.go delete mode 100644 backend/internal/db/categories.sql.go delete mode 100644 backend/internal/db/projects.sql.go delete mode 100644 backend/internal/db/schedules.sql.go delete mode 100644 backend/internal/service/views.go delete mode 100644 backend/queries/app_config.sql delete mode 100644 backend/queries/audit_logs.sql delete mode 100644 backend/queries/bookings.sql delete mode 100644 backend/queries/categories.sql delete mode 100644 backend/queries/projects.sql delete mode 100644 backend/queries/schedules.sql delete mode 100644 uniapp/utils/fallback.uts delete mode 100644 uniapp/utils/format.uts delete mode 100644 uniapp/utils/store.uts delete mode 100644 uniapp/utils/theme.uts diff --git a/.env.example b/.env.example index 8abc443..0c6df2b 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,9 @@ # Copy this file to .env and fill the values locally. # Do not commit the real .env file. +# Use a strong unique password; generate the session secret with: openssl rand -hex 32 -GUAHAO_ADMIN_AUTH_MODE=none +GUAHAO_ADMIN_USERNAME=admin +GUAHAO_ADMIN_PASSWORD= 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/README.md b/README.md index 8d4d11f..3c066e8 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,39 @@ -# Smile First 口腔护理预约小程序 +# 骨安通小程序 -Smile First 是一个微信小程序预约 MVP,覆盖首页、选择项目、选择日期与时间、确认预约、预约成功、我的预约,以及 Go 后端 API。 +当前小程序只保留骨科价格查询页:选择地区和项目,授权手机号后提交咨询;后台可查看和处理价格咨询。 ## 技术栈 -- 小程序:原生 WXML / WXSS / JS +- 前端:UniApp X(UVue / UTS) - 后端:Go、Fiber、sqlc、Cobra、logrus - 数据库:SQLite -- 部署:Docker / docker compose +- 部署: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 并插入默认项目、排期与时段。 +默认监听 `:9800`。启动时会应用 SQLite schema 和价格咨询相关迁移。 -管理后台入口: +管理后台:`http://127.0.0.1:9800/admin/login`,价格咨询列表:`/admin/price-inquiries`。 -- 登录页:`http://127.0.0.1:9800/admin/login` -- 默认页:`/admin/price-inquiries` -- 价格咨询派单:`/admin/price-inquiries`,展示授权手机号提交的地区、咨询项目、创建时间和更新时间 +后台使用本地账号密码登录,在 `.env` 配置 `GUAHAO_ADMIN_USERNAME`、强密码 `GUAHAO_ADMIN_PASSWORD` 和 `GUAHAO_ADMIN_SESSION_SECRET`(可用 `openssl rand -hex 32` 生成至少 32 字符的 session secret);登录失败按后端识别的连接来源 IP 限制 5 次/15 分钟;经反向代理访问时,建议同时在代理层限流,避免多个用户共享代理 IP 的应用限额。生产环境应只经可信反向代理访问后端,并由代理覆盖 `X-Forwarded-Proto`。体验版手机号授权需要配置 `GUAHAO_WECHAT_APPID` 和 `GUAHAO_WECHAT_SECRET`。真实 `.env` 不提交到代码库。 -后台登录模式、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 -``` +使用 HBuilderX 打开 `uniapp/`。当前注册页面为 `pages/price-query/price-query`。API 地址配置在 `uniapp/utils/api.uts`:本地开发使用 `http://localhost:9800`,体验版和正式版使用 `https://gh.yqbmb.com`。 -`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/...` 访问。 +新 schema 只创建用户、迁移标记和价格咨询表。旧预约相关表及数据不会被自动删除,但新版本不再提供预约、排期、分类或项目管理 API。 -## 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 +- `uniapp/`:小程序前端 +- `backend/`:Go API、SQL 查询和 SQLite schema - `backend/queries/`:sqlc 查询 -- `uni-guahao/static/`:小程序静态资源 -- `docs/design-elements.md`:设计元素、裁切坐标和 token +- `backend/migrations/schema.sql`:SQLite schema diff --git a/backend/Dockerfile b/backend/Dockerfile index 40e6dba..86ff32b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -37,7 +37,6 @@ 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 diff --git a/backend/cmd/smilefirst/main.go b/backend/cmd/smilefirst/main.go index aaf5edc..e8c4291 100644 --- a/backend/cmd/smilefirst/main.go +++ b/backend/cmd/smilefirst/main.go @@ -32,7 +32,6 @@ 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 { @@ -67,25 +66,9 @@ func newRootCommand(cfg *config.Config, handlers commandHandlers) *cobra.Command 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", + Short: "骨安通价格查询服务", } root.PersistentFlags().StringVar(&cfg.Addr, "addr", cfg.Addr, "HTTP listen address") root.PersistentFlags().StringVar(&cfg.DatabasePath, "db", cfg.DatabasePath, "SQLite database path") @@ -102,11 +85,6 @@ func newRootCommand(cfg *config.Config, handlers commandHandlers) *cobra.Command 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 } @@ -121,10 +99,6 @@ func openService(ctx context.Context, cfg config.Config, log *logrus.Logger) (*s 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 } diff --git a/backend/cmd/smilefirst/main_test.go b/backend/cmd/smilefirst/main_test.go index ad546b1..1cc3734 100644 --- a/backend/cmd/smilefirst/main_test.go +++ b/backend/cmd/smilefirst/main_test.go @@ -24,10 +24,6 @@ func TestRootCommandDefaultsToServe(t *testing.T) { calls = append(calls, "migrate") return nil }, - seed: func(cmd *cobra.Command, args []string) error { - calls = append(calls, "seed") - return nil - }, } cmd := newRootCommand(&cfg, handlers) diff --git a/backend/go.mod b/backend/go.mod index e0c97d1..8d62825 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,18 +3,15 @@ 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 diff --git a/backend/go.sum b/backend/go.sum index 82701e1..a90a7bb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,7 +1,5 @@ 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= @@ -9,8 +7,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp 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= @@ -67,8 +63,6 @@ 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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2227495..a7799cc 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,99 +9,49 @@ import ( ) 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 + Addr string + DatabasePath string + SchemaPath string + LogLevel string + AllowOrigins string + WeChatAppID string + WeChatAppSecret string + WeChatAPIBase string + AdminUsername string + AdminPassword string + AdminSessionSecret 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"), + 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", "*"), + WeChatAppID: env("GUAHAO_WECHAT_APPID", ""), + WeChatAppSecret: env("GUAHAO_WECHAT_SECRET", ""), + WeChatAPIBase: env("GUAHAO_WECHAT_API_BASE", "https://api.weixin.qq.com"), + AdminUsername: env("GUAHAO_ADMIN_USERNAME", ""), + AdminPassword: env("GUAHAO_ADMIN_PASSWORD", ""), + AdminSessionSecret: env("GUAHAO_ADMIN_SESSION_SECRET", ""), } } -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.AdminUsername) == "" { + missing = append(missing, "GUAHAO_ADMIN_USERNAME") } - if strings.TrimSpace(c.OIDCClientID) == "" { - missing = append(missing, "GUAHAO_OIDC_CLIENT_ID") + if strings.TrimSpace(c.AdminPassword) == "" { + missing = append(missing, "GUAHAO_ADMIN_PASSWORD") } - 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(strings.TrimSpace(c.AdminSessionSecret)) < 32 { + missing = append(missing, "GUAHAO_ADMIN_SESSION_SECRET (at least 32 characters)") } if len(missing) > 0 { - return fmt.Errorf("missing OIDC admin configuration: set %s in .env or environment", strings.Join(missing, ", ")) + return fmt.Errorf("invalid admin login configuration: set %s in .env or environment", strings.Join(missing, ", ")) } return nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 3f5f3c6..b93e60a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -3,57 +3,53 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) -func TestLoadReadsAdminAuthFromDotEnv(t *testing.T) { +func TestLoadReadsAdminCredentialsFromDotEnv(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 { + sessionSecret := strings.Repeat("s", 32) + envData := "GUAHAO_ADMIN_USERNAME='local-admin'\nGUAHAO_ADMIN_PASSWORD='local-password'\nGUAHAO_ADMIN_SESSION_SECRET='" + sessionSecret + "'\n" + if err := os.WriteFile(".env", []byte(envData), 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) + if cfg.AdminUsername != "local-admin" || cfg.AdminPassword != "local-password" || cfg.AdminSessionSecret != sessionSecret { + t.Fatalf("loaded admin credentials = (%q, %q, %q), want configured .env values", cfg.AdminUsername, cfg.AdminPassword, cfg.AdminSessionSecret) } } -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 TestValidateAdminAuthRequiresCredentials(t *testing.T) { + base := Config{AdminUsername: "admin", AdminPassword: "password", AdminSessionSecret: strings.Repeat("s", 32)} + for _, test := range []struct { + name string + variable string + edit func(*Config) + }{ + {"missing username", "GUAHAO_ADMIN_USERNAME", func(cfg *Config) { cfg.AdminUsername = "" }}, + {"missing password", "GUAHAO_ADMIN_PASSWORD", func(cfg *Config) { cfg.AdminPassword = "" }}, + {"missing session secret", "GUAHAO_ADMIN_SESSION_SECRET", func(cfg *Config) { cfg.AdminSessionSecret = "" }}, + {"short session secret", "GUAHAO_ADMIN_SESSION_SECRET", func(cfg *Config) { cfg.AdminSessionSecret = "weak" }}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := base + test.edit(&cfg) + err := cfg.ValidateAdminAuth() + if err == nil || !strings.Contains(err.Error(), test.variable) { + t.Fatalf("ValidateAdminAuth() error = %v, want %s", err, test.variable) + } + }) } } -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", - } +func TestValidateAdminAuthAcceptsCredentials(t *testing.T) { + cfg := Config{AdminUsername: "admin", AdminPassword: "password", AdminSessionSecret: strings.Repeat("s", 32)} if err := cfg.ValidateAdminAuth(); err != nil { t.Fatalf("ValidateAdminAuth() error = %v, want nil", err) } @@ -62,17 +58,12 @@ func TestValidateAdminAuthOIDCRequiresConfiguration(t *testing.T) { func clearAdminEnv(t *testing.T) func() { t.Helper() keys := []string{ - "GUAHAO_ADMIN_AUTH_MODE", + "GUAHAO_ADMIN_USERNAME", + "GUAHAO_ADMIN_PASSWORD", "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 { diff --git a/backend/internal/db/app_config.sql.go b/backend/internal/db/app_config.sql.go deleted file mode 100644 index d36312d..0000000 --- a/backend/internal/db/app_config.sql.go +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100644 index 42def43..0000000 --- a/backend/internal/db/audit_logs.sql.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 deleted file mode 100644 index 3f69068..0000000 --- a/backend/internal/db/bookings.sql.go +++ /dev/null @@ -1,282 +0,0 @@ -// 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 deleted file mode 100644 index ff2c3cb..0000000 --- a/backend/internal/db/categories.sql.go +++ /dev/null @@ -1,167 +0,0 @@ -// 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/models.go b/backend/internal/db/models.go index b17b78a..2c6fd56 100644 --- a/backend/internal/db/models.go +++ b/backend/internal/db/models.go @@ -14,30 +14,6 @@ type AppConfig struct { 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"` @@ -53,48 +29,6 @@ type PriceInquiry struct { 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"` diff --git a/backend/internal/db/projects.sql.go b/backend/internal/db/projects.sql.go deleted file mode 100644 index 6f26361..0000000 --- a/backend/internal/db/projects.sql.go +++ /dev/null @@ -1,272 +0,0 @@ -// 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 deleted file mode 100644 index e8e0a56..0000000 --- a/backend/internal/db/schedules.sql.go +++ /dev/null @@ -1,288 +0,0 @@ -// 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 index a4cea55..45ee2d7 100644 --- a/backend/internal/db/users.sql.go +++ b/backend/internal/db/users.sql.go @@ -110,83 +110,6 @@ func (q *Queries) GetUserByOpenID(ctx context.Context, openid string) (User, err 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 diff --git a/backend/internal/httpapi/admin.go b/backend/internal/httpapi/admin.go index 53abb3b..6eda5fb 100644 --- a/backend/internal/httpapi/admin.go +++ b/backend/internal/httpapi/admin.go @@ -2,29 +2,24 @@ 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/gofiber/fiber/v3/middleware/limiter" "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 @@ -32,20 +27,15 @@ 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 + cfg config.Config + svc *service.Service + templates *template.Template } type adminPageData struct { @@ -55,12 +45,6 @@ type adminPageData struct { 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 @@ -91,9 +75,12 @@ func newAdminUI(cfg config.Config, svc *service.Service) *adminUI { 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) + // ponytail: process-local limiter fits the single-container deployment; use shared storage if scaled. + app.Post("/admin/login", limiter.New(limiter.Config{ + Max: 5, + Expiration: 15 * time.Minute, + SkipSuccessfulRequests: true, + }), a.login) app.Post("/admin/logout", a.logout) } @@ -106,13 +93,6 @@ func (a *adminUI) registerPages(admin fiber.Router) { } 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() @@ -125,108 +105,30 @@ func (a *adminUI) requireLogin(c fiber.Ctx) error { } 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), + PageTitle: "后台登录", + Next: safeAdminNext(c.Query("next")), }) } -func (a *adminUI) loginRedirect(c fiber.Ctx) error { - if a.cfg.AdminAuthDisabled() { - return c.Redirect().To(defaultAdminPage) - } +func (a *adminUI) login(c fiber.Ctx) error { next := safeAdminNext(c.FormValue("next")) - return c.Redirect().To("/admin/oidc/start?next=" + url.QueryEscape(next)) -} + usernameMatches := constantTimeEqual(c.FormValue("username"), a.cfg.AdminUsername) + passwordMatches := constantTimeEqual(c.FormValue("password"), a.cfg.AdminPassword) + if a.cfg.AdminUsername == "" || a.cfg.AdminPassword == "" || len(strings.TrimSpace(a.cfg.AdminSessionSecret)) < 32 || !usernameMatches || !passwordMatches { + c.Status(fiber.StatusUnauthorized) + return a.render(c, "login.html", adminPageData{ + PageTitle: "后台登录", + Next: next, + Error: "用户名或密码错误", + }) + } -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("授权身份已过期,请重新登录")) - } + user := adminUser{Subject: a.cfg.AdminUsername, DisplayName: a.cfg.AdminUsername} c.Cookie(&fiber.Cookie{ Name: adminSessionCookie, Value: a.signSession(user, expires), @@ -234,9 +136,10 @@ func (a *adminUI) oidcCallback(c fiber.Ctx) error { MaxAge: int(time.Until(expires).Seconds()), Expires: expires, HTTPOnly: true, + Secure: adminCookieSecure(c), SameSite: "Lax", }) - return c.Redirect().To(state.Next) + return c.Redirect().To(next) } func (a *adminUI) logout(c fiber.Ctx) error { @@ -247,6 +150,7 @@ func (a *adminUI) logout(c fiber.Ctx) error { MaxAge: -1, Expires: time.Now().Add(-time.Hour), HTTPOnly: true, + Secure: adminCookieSecure(c), SameSite: "Lax", }) return c.Redirect().To("/admin/login") @@ -399,7 +303,6 @@ 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 @@ -419,21 +322,6 @@ type adminSession struct { 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, @@ -457,21 +345,6 @@ func (a *adminUI) validSession(token string) (adminUser, bool) { 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 { @@ -483,6 +356,9 @@ func (a *adminUI) signCookieValue(value any) string { } func (a *adminUI) verifyCookieValue(token string, value any) bool { + if len(strings.TrimSpace(a.cfg.AdminSessionSecret)) < 32 { + return false + } parts := strings.Split(token, ".") if len(parts) != 2 { return false @@ -502,56 +378,11 @@ func (a *adminUI) verifyCookieValue(token string, value any) bool { } 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 := hmac.New(sha256.New, []byte(a.cfg.AdminSessionSecret)) 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) @@ -561,36 +392,10 @@ func currentAdminUser(c fiber.Ctx) (adminUser, bool) { 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 + aHash := sha256.Sum256([]byte(a)) + bHash := sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(aHash[:], bHash[:]) == 1 } func wantsHTML(c fiber.Ctx) bool { @@ -600,37 +405,14 @@ func wantsHTML(c fiber.Ctx) bool { func safeAdminNext(next string) string { next = strings.TrimSpace(next) - if strings.HasPrefix(next, "/admin") && !strings.HasPrefix(next, "/admin/login") { + if (next == "/admin" || 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 adminCookieSecure(c fiber.Ctx) bool { + return c.Scheme() == "https" || strings.EqualFold(c.Get("X-Forwarded-Proto"), "https") } func redirectPriceInquiries(c fiber.Ctx, filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) error { @@ -669,10 +451,3 @@ func priceInquiriesURL(filter service.AdminPriceInquiryFilter, page, pageSize in } 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 index 9e6502d..0d815b4 100644 --- a/backend/internal/httpapi/admin_test.go +++ b/backend/internal/httpapi/admin_test.go @@ -22,14 +22,13 @@ import ( _ "modernc.org/sqlite" ) +const testAdminSessionSecret = "0123456789abcdef0123456789abcdef" + 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", + AdminUsername: "test-admin", + AdminPassword: "test-password", + AdminSessionSecret: testAdminSessionSecret, }) defer closeDB() @@ -49,8 +48,8 @@ func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) { } cookie := adminSessionCookieHeader(config.Config{ - AdminSessionSecret: "test-session-secret", - }, adminUser{Subject: "logto-user-1", DisplayName: "王医生"}) + AdminSessionSecret: testAdminSessionSecret, + }, adminUser{Subject: "test-admin", DisplayName: "王医生"}) resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{ "Cookie": cookie, "Accept": "text/html", @@ -90,41 +89,21 @@ func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) { t.Fatalf("price inquiries page rendered removed title or ID column: %s", body) } if !strings.Contains(body, "王医生") { - t.Fatalf("price inquiries page did not render OIDC user name: %s", body) + t.Fatalf("price inquiries page did not render admin name: %s", body) } if strings.Contains(body, "/admin/phones") || strings.Contains(body, "/admin/booking-projects") || strings.Contains(body, "/admin/project-config") { t.Fatalf("price inquiries page should not render removed module links: %s", body) } - if !strings.Contains(body, "修改密码") || !strings.Contains(body, "https://auth.example.com/account/password?redirect=https%3A%2F%2Fgh.yqbmb.com%2Fadmin%2Fprice-inquiries&show_success=true") { - t.Fatalf("price inquiries page did not render Logto password change link: %s", body) + if strings.Contains(body, "修改密码") || strings.Contains(body, "OIDC") { + t.Fatalf("price inquiries page rendered removed OIDC controls: %s", body) } } -func TestAdminNoneModeAllowsPagesAndShowsDisabledAuthName(t *testing.T) { - app, closeDB := newTestAdminApp(t) - defer closeDB() - - resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{ - "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, "价格咨询派单") { - t.Fatalf("none mode page did not render expected admin state: %s", body) - } -} - -func TestAdminLoginPageRendersOIDCEntry(t *testing.T) { +func TestAdminLoginPageRendersCredentialForm(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", + AdminUsername: "operator", + AdminPassword: "local-password", + AdminSessionSecret: testAdminSessionSecret, }) defer closeDB() @@ -136,11 +115,83 @@ func TestAdminLoginPageRendersOIDCEntry(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body) } - if !strings.Contains(body, "授权登录") || !strings.Contains(body, "/admin/oidc/start?next=%2Fadmin%2Fprice-inquiries") { - t.Fatalf("login page did not render OIDC login entry: %s", body) + for _, expected := range []string{`action="/admin/login"`, `name="username"`, `name="password"`, `name="next"`} { + if !strings.Contains(body, expected) { + t.Fatalf("login page missing %q: %s", expected, body) + } } - if strings.Contains(body, `name="username"`) || strings.Contains(body, `name="password"`) { - t.Fatalf("login page should not render local credential form: %s", body) + if strings.Contains(body, "OIDC") || strings.Contains(body, "/admin/oidc/") { + t.Fatalf("login page rendered removed OIDC controls: %s", body) + } +} + +func TestAdminLoginAcceptsCredentialsAndSetsSession(t *testing.T) { + app, closeDB := newTestAdminAppWithConfig(t, config.Config{ + AdminUsername: "operator", + AdminPassword: "local-password", + AdminSessionSecret: testAdminSessionSecret, + }) + defer closeDB() + + invalid := url.Values{"username": {"operator"}, "password": {"wrong"}, "next": {"/admin"}} + resp := doRequest(t, app, http.MethodPost, "/admin/login", invalid.Encode(), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + body := readBody(t, resp) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(body, "用户名或密码错误") { + t.Fatalf("invalid login status/body = %d/%s, want 401 with generic error", resp.StatusCode, body) + } + if resp.Header.Get("Set-Cookie") != "" { + t.Fatalf("invalid login issued a cookie: %s", resp.Header.Get("Set-Cookie")) + } + + valid := url.Values{"username": {"operator"}, "password": {"local-password"}, "next": {"/admin/price-inquiries"}} + resp = doRequest(t, app, http.MethodPost, "/admin/login", valid.Encode(), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/admin/price-inquiries" { + t.Fatalf("valid login status/location = %d/%q, want redirect to admin page", resp.StatusCode, resp.Header.Get("Location")) + } + var session *http.Cookie + for _, cookie := range resp.Cookies() { + if cookie.Name == adminSessionCookie { + session = cookie + break + } + } + if session == nil || !session.HttpOnly || session.Path != "/admin" || session.SameSite != http.SameSiteLaxMode { + t.Fatalf("invalid admin session cookie: %#v", session) + } + + resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{ + "Cookie": session.Name + "=" + session.Value, + "Accept": "text/html", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("authenticated page status = %d, want %d", resp.StatusCode, http.StatusOK) + } +} + +func TestAdminLoginRateLimit(t *testing.T) { + app, closeDB := newTestAdminApp(t) + defer closeDB() + + body := url.Values{"username": {"wrong"}, "password": {"wrong"}}.Encode() + for attempt := 1; attempt <= 6; attempt++ { + resp := doRequest(t, app, http.MethodPost, "/admin/login", body, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + resp.Body.Close() + want := http.StatusUnauthorized + if attempt == 6 { + want = http.StatusTooManyRequests + } + if resp.StatusCode != want { + t.Fatalf("attempt %d status = %d, want %d", attempt, resp.StatusCode, want) + } } } @@ -154,7 +205,27 @@ func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) { path string body string }{ + {method: http.MethodGet, path: "/api/app/bootstrap"}, + {method: http.MethodGet, path: "/api/projects"}, + {method: http.MethodGet, path: "/api/bookings"}, + {method: http.MethodPost, path: "/api/bookings", body: "{}"}, + {method: http.MethodGet, path: "/api/bookings/old"}, + {method: http.MethodPatch, path: "/api/bookings/old/cancel", body: "{}"}, + {method: http.MethodGet, path: "/admin/oidc/start"}, + {method: http.MethodGet, path: "/admin/oidc/callback"}, {method: http.MethodGet, path: "/admin/phones"}, + {method: http.MethodGet, path: "/admin/categories"}, + {method: http.MethodPost, path: "/admin/categories", body: "{}"}, + {method: http.MethodGet, path: "/admin/projects"}, + {method: http.MethodPost, path: "/admin/projects", body: "{}"}, + {method: http.MethodGet, path: "/admin/schedules"}, + {method: http.MethodPost, path: "/admin/schedules/days", body: "{}"}, + {method: http.MethodPatch, path: "/admin/schedules/days/day", body: "{}"}, + {method: http.MethodPost, path: "/admin/schedules/slots", body: "{}"}, + {method: http.MethodPatch, path: "/admin/schedules/slots/slot", body: "{}"}, + {method: http.MethodGet, path: "/admin/bookings"}, + {method: http.MethodPatch, path: "/admin/bookings/booking/status", body: "{}"}, + {method: http.MethodGet, path: "/admin/reports/bookings"}, {method: http.MethodGet, path: "/admin/booking-projects"}, {method: http.MethodGet, path: "/admin/project-config"}, {method: http.MethodPost, path: "/admin/project-config/projects", body: "id=project_fresh_clean"}, @@ -162,11 +233,12 @@ func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) { resp := doRequest(t, app, route.method, route.path, route.body, map[string]string{ "Cookie": cookie, "Accept": "text/html", - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", }) - defer resp.Body.Close() + body := readBody(t, resp) + resp.Body.Close() if resp.StatusCode != http.StatusNotFound { - t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, readBody(t, resp)) + t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, body) } } } @@ -636,53 +708,19 @@ func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber. 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) + cfg := cfgOverride + cfg.AllowOrigins = "*" + if cfg.WeChatAPIBase == "" { + cfg.WeChatAPIBase = "https://api.weixin.qq.com" } - session, err := svc.Login(ctx, "admin_test") - if err != nil { - database.Close() - t.Fatalf("login seeded user: %v", err) + if cfg.AdminUsername == "" { + cfg.AdminUsername = "test-admin" } - if _, err := svc.BindPhone(ctx, session.User.Openid, "13900001111"); err != nil { - database.Close() - t.Fatalf("bind seeded phone: %v", err) + if cfg.AdminPassword == "" { + cfg.AdminPassword = "test-password" } - _, 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" + if cfg.AdminSessionSecret == "" { + cfg.AdminSessionSecret = testAdminSessionSecret } return New(cfg, svc, log), func() { database.Close() } } @@ -738,7 +776,10 @@ func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) st func loginAdmin(t *testing.T, app *fiber.App) string { t.Helper() - return "" + return adminSessionCookieHeader(config.Config{AdminSessionSecret: testAdminSessionSecret}, adminUser{ + Subject: "test-admin", + DisplayName: "test-admin", + }) } func adminSessionCookieHeader(cfg config.Config, user adminUser) string { diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go index e61e6db..944d3d9 100644 --- a/backend/internal/httpapi/httpapi.go +++ b/backend/internal/httpapi/httpapi.go @@ -1,13 +1,8 @@ package httpapi import ( - "bytes" "database/sql" - "encoding/csv" "errors" - "path" - "path/filepath" - "strconv" "strings" "time" @@ -16,7 +11,6 @@ import ( "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" ) @@ -24,7 +18,7 @@ import ( 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", + AppName: "骨安通 API", BodyLimit: 8 * 1024 * 1024, ErrorHandler: errorHandler, }) @@ -48,50 +42,11 @@ func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App })) 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"` @@ -166,17 +121,6 @@ func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App }).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 { @@ -211,231 +155,14 @@ func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App "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) { @@ -504,11 +231,3 @@ func splitList(value string) []string { } 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 index 758aaa1..d8c49a5 100644 --- a/backend/internal/httpapi/templates/base.html +++ b/backend/internal/httpapi/templates/base.html @@ -386,7 +386,6 @@ 价格咨询派单
{{.Username}} - {{if .PasswordChangeURL}}修改密码{{end}}
diff --git a/backend/internal/httpapi/templates/login.html b/backend/internal/httpapi/templates/login.html index 5965c3c..91ada00 100644 --- a/backend/internal/httpapi/templates/login.html +++ b/backend/internal/httpapi/templates/login.html @@ -6,9 +6,20 @@

后台登录

管理后台
{{if .Error}}
{{.Error}}
{{end}} -
- 授权登录 -
+
+ +
+ + +
+
+ + +
+
+ +
+
diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index f548a2b..a3c801b 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -3,35 +3,11 @@ 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"` @@ -69,80 +45,6 @@ type AdminPriceInquiryPage struct { 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 @@ -245,21 +147,6 @@ func normalizeAdminPriceInquiryFilter(filter AdminPriceInquiryFilter) AdminPrice } } -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": diff --git a/backend/internal/service/phone_test.go b/backend/internal/service/phone_test.go index b854e06..48e10d1 100644 --- a/backend/internal/service/phone_test.go +++ b/backend/internal/service/phone_test.go @@ -52,15 +52,12 @@ WHERE phone = ?`, "13900001111").Scan(&phoneOwners); err != nil { t.Fatalf("first user phone = %#v, want cleared", firstUser.Phone) } - records, err := svc.AdminPhoneRecords(ctx) + secondUser, err := svc.Q.GetUserByOpenID(ctx, second.User.Openid) if err != nil { - t.Fatalf("list admin phone records: %v", err) + t.Fatalf("reload second user: %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]) + if !secondUser.Phone.Valid || secondUser.Phone.String != "13900001111" { + t.Fatalf("second user phone = %#v, want bound phone", secondUser.Phone) } } diff --git a/backend/internal/service/schema_test.go b/backend/internal/service/schema_test.go index 0520e62..156f8b6 100644 --- a/backend/internal/service/schema_test.go +++ b/backend/internal/service/schema_test.go @@ -10,7 +10,7 @@ import ( _ "modernc.org/sqlite" ) -func TestApplySchemaDropsLegacyConfigColumns(t *testing.T) { +func TestApplySchemaPreservesLegacyAppointmentData(t *testing.T) { database, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) @@ -42,6 +42,9 @@ CREATE TABLE projects ( updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +INSERT INTO project_categories (id, name) VALUES ('cat_old', '旧分类'); +INSERT INTO projects (id, category_id, name, price_text) VALUES ('project_old', 'cat_old', '旧项目', ''); + CREATE TABLE price_inquiries ( id TEXT PRIMARY KEY, user_id TEXT, @@ -64,7 +67,7 @@ CREATE TABLE price_inquiries ( t.Fatalf("apply schema: %v", err) } - removedColumns := []struct { + legacyColumns := []struct { table string column string }{ @@ -72,25 +75,25 @@ CREATE TABLE price_inquiries ( {table: "projects", column: "duration_minutes"}, {table: "projects", column: "notice"}, } - for _, item := range removedColumns { + for _, item := range legacyColumns { 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) + if !exists { + t.Fatalf("%s.%s was dropped from existing data", item.table, item.column) } } - exists, err := columnExists(ctx, database, "projects", "image") - if err != nil { - t.Fatalf("check projects.image: %v", err) + var legacyProjectName string + if err := database.QueryRowContext(ctx, "SELECT name FROM projects WHERE id = 'project_old'").Scan(&legacyProjectName); err != nil { + t.Fatalf("read existing project: %v", err) } - if !exists { - t.Fatal("projects.image was not added by schema migration") + if legacyProjectName != "旧项目" { + t.Fatalf("existing project name = %q, want 旧项目", legacyProjectName) } - exists, err = columnExists(ctx, database, "price_inquiries", "remark") + exists, err := columnExists(ctx, database, "price_inquiries", "remark") if err != nil { t.Fatalf("check price_inquiries.remark: %v", err) } @@ -260,4 +263,3 @@ SELECT last_submitted_at FROM price_inquiries WHERE id = ?`, id).Scan(&lastSubmi 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 index 2d4e50a..4268c88 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" "github.com/sirupsen/logrus" @@ -20,39 +19,6 @@ type Service struct { 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"` @@ -61,26 +27,12 @@ type CreatePriceInquiryRequest struct { 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" -) +const priceInquiryShanghaiTimeMigrationKey = "migration.price_inquiries.asia_shanghai.v1" var priceInquiryProjects = []string{ "半月板损伤", @@ -118,18 +70,9 @@ func ApplySchema(ctx context.Context, database *sql.DB, schemaPath string) error 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 } @@ -271,13 +214,6 @@ INSERT INTO app_config (config_key, config_value) VALUES (?, 'applied')`, priceI 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 { @@ -299,30 +235,6 @@ WHERE type = 'table' AND name = ?`, table).Scan(&name) 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 { @@ -348,19 +260,6 @@ func columnExists(ctx context.Context, database *sql.DB, table, column string) ( 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 @@ -372,213 +271,6 @@ func EnsureDatabaseDir(path string) error { 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 == "" { @@ -717,263 +409,3 @@ func isPriceInquiryProject(name string) bool { } 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 deleted file mode 100644 index ad010ad..0000000 --- a/backend/internal/service/views.go +++ /dev/null @@ -1,83 +0,0 @@ -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 index 4451c9c..01cccb2 100644 --- a/backend/migrations/schema.sql +++ b/backend/migrations/schema.sql @@ -14,69 +14,13 @@ 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 -); - +-- Retained for one-time price-inquiry migration markers. 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), @@ -95,24 +39,5 @@ CREATE TABLE IF NOT EXISTS price_inquiries ( 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); +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 deleted file mode 100644 index b24b88d..0000000 --- a/backend/queries/app_config.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 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 deleted file mode 100644 index 294cd3e..0000000 --- a/backend/queries/audit_logs.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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 deleted file mode 100644 index ecc2158..0000000 --- a/backend/queries/bookings.sql +++ /dev/null @@ -1,40 +0,0 @@ --- 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 deleted file mode 100644 index fd973f3..0000000 --- a/backend/queries/categories.sql +++ /dev/null @@ -1,26 +0,0 @@ --- 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/projects.sql b/backend/queries/projects.sql deleted file mode 100644 index eeb91db..0000000 --- a/backend/queries/projects.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 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 deleted file mode 100644 index 4bc6e39..0000000 --- a/backend/queries/schedules.sql +++ /dev/null @@ -1,53 +0,0 @@ --- 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 index eef6d3a..97caee0 100644 --- a/backend/queries/users.sql +++ b/backend/queries/users.sql @@ -22,18 +22,3 @@ 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/docker-compose.yml b/docker-compose.yml index 0d46e35..99e124c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,16 +5,11 @@ services: 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_ADMIN_USERNAME: "${GUAHAO_ADMIN_USERNAME:?Set GUAHAO_ADMIN_USERNAME in .env}" + GUAHAO_ADMIN_PASSWORD: "${GUAHAO_ADMIN_PASSWORD:?Set GUAHAO_ADMIN_PASSWORD in .env}" + GUAHAO_ADMIN_SESSION_SECRET: "${GUAHAO_ADMIN_SESSION_SECRET:?Set GUAHAO_ADMIN_SESSION_SECRET in .env}" GUAHAO_WECHAT_APPID: "${GUAHAO_WECHAT_APPID:-}" GUAHAO_WECHAT_SECRET: "${GUAHAO_WECHAT_SECRET:-}" TZ: "Asia/Shanghai" diff --git a/uniapp/pages/price-query/price-query.uvue b/uniapp/pages/price-query/price-query.uvue index b8c0851..076cda8 100644 --- a/uniapp/pages/price-query/price-query.uvue +++ b/uniapp/pages/price-query/price-query.uvue @@ -110,7 +110,6 @@