chore: remove legacy booking flows and use local admin auth
Build and push backend image / backend-image (push) Successful in 52s

This commit is contained in:
2026-09-23 21:26:58 +08:00
parent 5ece4530e5
commit 7e41bcaaee
43 changed files with 285 additions and 3547 deletions
+3 -9
View File
@@ -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
+15 -58
View File
@@ -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
-1
View File
@@ -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
+1 -27
View File
@@ -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
}
-4
View File
@@ -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)
-3
View File
@@ -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
-6
View File
@@ -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=
+29 -79
View File
@@ -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
}
+31 -40
View File
@@ -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 {
-43
View File
@@ -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
}
-52
View File
@@ -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
}
-282
View File
@@ -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
}
-167
View File
@@ -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
}
-66
View File
@@ -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"`
-272
View File
@@ -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
}
-288
View File
@@ -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
}
-77
View File
@@ -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
+37 -262
View File
@@ -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)
}
+128 -87
View File
@@ -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&amp;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 {
+1 -282
View File
@@ -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
}
@@ -386,7 +386,6 @@
<a class="brand" href="/admin/price-inquiries">价格咨询派单</a>
<form class="logout-form" method="post" action="/admin/logout">
<span class="session-name">{{.Username}}</span>
{{if .PasswordChangeURL}}<a class="ghost-button" href="{{.PasswordChangeURL}}">修改密码</a>{{end}}
<button class="ghost-button" type="submit">退出登录</button>
</form>
</header>
+14 -3
View File
@@ -6,9 +6,20 @@
<h1>后台登录</h1>
<div class="muted">管理后台</div>
{{if .Error}}<div class="error" style="margin-top: 18px;">{{.Error}}</div>{{end}}
<div class="actions" style="margin-top: 22px;">
<a class="button" href="{{.OIDCLoginURL}}">授权登录</a>
</div>
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="username">用户名</label>
<input id="username" name="username" autocomplete="username" required>
</div>
<div class="field" style="margin-top: 14px;">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="actions" style="margin-top: 22px;">
<button class="button" type="submit">登录</button>
</div>
</form>
</section>
</main>
</body>
-113
View File
@@ -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":
+4 -7
View File
@@ -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)
}
}
+14 -12
View File
@@ -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)
}
}
+1 -569
View File
@@ -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,
}
}
-83
View File
@@ -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
}
+3 -78
View File
@@ -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);
-11
View File
@@ -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 *;
-7
View File
@@ -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 *;
-40
View File
@@ -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 *;
-26
View File
@@ -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 = ?;
-45
View File
@@ -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 = ?;
-53
View File
@@ -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 = ?;
-15
View File
@@ -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;
+3 -8
View File
@@ -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"
+1 -4
View File
@@ -110,7 +110,6 @@
<script setup lang="uts">
import { post } from '../../utils/api.uts'
import { getDraft, setPhone } from '../../utils/store.uts'
import { bindPhoneFromEvent, currentUser, openidFromUser, phoneFromUser } from '../../utils/user.uts'
const PRICE_INQUIRY_PROJECTS = [
@@ -245,7 +244,6 @@
updateCanAuthorize()
bindPhoneFromEvent(event).then((res) => {
const phone = (res as any).phone as string
setPhone(phone)
state.phone = phone
state.hasPhone = phone.length > 0
return submitInquiry()
@@ -302,9 +300,8 @@
}
const refreshPhone = (): boolean => {
const draft = getDraft()
const user = currentUser()
const phone = draft.phone.length > 0 ? draft.phone : phoneFromUser(user)
const phone = phoneFromUser(user)
state.phone = phone
state.hasPhone = phone.length > 0
return state.hasPhone
-15
View File
@@ -53,21 +53,6 @@ export function request(method: RequestMethod, path: string, data: any | null =
})
}
export function get(path: string): Promise<any> {
return request('GET' as RequestMethod, path, null)
}
export function post(path: string, data: any): Promise<any> {
return request('POST' as RequestMethod, path, data)
}
export function patch(path: string, data: any): Promise<any> {
return request('PATCH' as RequestMethod, path, data)
}
export function mediaUrl(value: string | null): string {
if (value == null || value.length == 0) return ''
if (value.indexOf('http://') == 0 || value.indexOf('https://') == 0) return value
if (value.indexOf('/') == 0) return apiBase() + value
return value
}
-93
View File
@@ -1,93 +0,0 @@
import { Category, Day, Project, Slot } from './types.uts'
import { formatDate } from './format.uts'
import { PROJECT_PLACEHOLDER_IMAGE } from './theme.uts'
import { mediaUrl } from './api.uts'
export function decorateCategories(categories: Category[]): Category[] {
const iconMap = ['heart', 'search', 'smile', 'service', 'user']
const imageKeys = ['daily', 'clean', 'white', 'breath', 'child']
const result: Category[] = []
for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex += 1) {
const category = categories[categoryIndex]
const rawImageKey = category.image_key == null ? '' : category.image_key as string
const imageKey = rawImageKey.length > 0 ? rawImageKey : imageKeys[categoryIndex % imageKeys.length]
const projects: Project[] = []
for (let projectIndex = 0; projectIndex < category.projects.length; projectIndex += 1) {
const project = category.projects[projectIndex]
const rawProjectImageKey = project.image_key == null ? '' : project.image_key as string
const rawProjectImage = project.image == null ? '' : project.image as string
const projectImageKey = rawProjectImageKey.length > 0 ? rawProjectImageKey : imageKey
const projectImage = rawProjectImage.length > 0 ? mediaUrl(rawProjectImage) : ''
projects.push({
id: project.id,
category_id: project.category_id,
name: project.name,
subtitle: project.subtitle,
price_text: project.price_text,
image: projectImage.length > 0 ? projectImage : PROJECT_PLACEHOLDER_IMAGE,
image_key: projectImageKey,
sortIndex: projectIndex + 1
} as Project)
}
const rawIcon = category.icon == null ? '' : category.icon as string
result.push({
id: category.id,
name: category.name,
icon: rawIcon.length > 0 ? rawIcon : iconMap[categoryIndex % iconMap.length],
image_key: imageKey,
projects
} as Category)
}
return result
}
export function fallbackCategories(): Category[] {
return [
{ id: 'cat_meniscus_injury', name: '半月板损伤', image_key: 'daily', projects: [] } as Category,
{ id: 'cat_synovitis', name: '滑膜炎', image_key: 'clean', projects: [] } as Category,
{ id: 'cat_knee_arthritis', name: '膝关节炎', image_key: 'white', projects: [] } as Category,
{ id: 'cat_lumbar_disc_herniation', name: '腰椎间盘突出', image_key: 'breath', projects: [] } as Category,
{ id: 'cat_lumbar_muscle_strain', name: '腰肌劳损', image_key: 'child', projects: [] } as Category,
{ id: 'cat_lumbar_spinal_stenosis', name: '腰椎管狭窄', image_key: 'daily', projects: [] } as Category,
{ id: 'cat_cervical_spondylosis', name: '颈椎病', image_key: 'clean', projects: [] } as Category,
{ id: 'cat_lumbar_spondylosis', name: '腰椎病', image_key: 'white', projects: [] } as Category
]
}
export function fallbackDays(projectId: string): Day[] {
const starts = ['09:30', '10:30', '13:00', '14:00', '18:00', '19:00', '20:00']
const base = new Date()
const days: Day[] = []
for (let i = 1; i <= 21; i += 1) {
const date = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i)
const dateText = formatDate(date)
const dayId = scheduleDayId(projectId, dateText)
const slots: Slot[] = []
for (let idx = 0; idx < starts.length; idx += 1) {
const start = starts[idx]
slots.push({
id: slotId(dayId, start),
start_time: start,
status: 'available'
} as Slot)
}
days.push({
date: dateText,
status: 'available',
slots
} as Day)
}
return days
}
function scheduleDayId(projectId: string, dateText: string): string {
return 'day_' + projectId + '_' + compactDate(dateText)
}
function slotId(dayId: string, startTime: string): string {
return 'slot_' + dayId + '_' + startTime.replace(':', '')
}
function compactDate(dateText: string): string {
return dateText.split('-').join('')
}
-84
View File
@@ -1,84 +0,0 @@
import { BookingDetail, Day, Slot } from './types.uts'
export function formatDateText(date: string): string {
if (date.length == 0) return ''
const parts = date.split('-')
if (parts.length < 3) return date
return parseInt(parts[1]).toString() + '月' + parseInt(parts[2]).toString() + '日'
}
export function maskPhone(phone: string): string {
if (phone.length < 7) return phone
return phone.slice(0, 3) + ' **** ' + phone.slice(phone.length - 4)
}
export function pad(value: number): string {
if (value < 10) return '0' + value.toString()
return value.toString()
}
export function formatMonth(date: Date): string {
return date.getFullYear().toString() + '-' + pad(date.getMonth() + 1)
}
export function formatDate(date: Date): string {
return date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
}
export function timestampFromDate(dateText: string): number {
if (dateText.length == 0) return 0
const parts = dateText.split('-')
if (parts.length < 3) return 0
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])).getTime()
}
export function normalizeSlots(slots: Slot[]): Slot[] {
const result: Slot[] = []
for (let i = 0; i < slots.length; i += 1) {
const slot = slots[i]
result.push({
id: slot.id,
start_time: slot.start_time,
status: 'available',
statusText: '可约'
} as Slot)
}
return result
}
export function bookingStatusText(status: string): string {
if (status == 'pending') return '待确认'
if (status == 'confirmed') return '已预约'
if (status == 'cancelled_by_user') return '已取消'
if (status == 'cancelled_by_operator') return '已取消'
if (status == 'completed') return '已完成'
if (status == 'expired') return '已失效'
return status
}
export function normalizeBookings(bookings: BookingDetail[]): BookingDetail[] {
const result: BookingDetail[] = []
for (let i = 0; i < bookings.length; i += 1) {
const item = bookings[i]
const status = item.booking.status
result.push({
booking: item.booking,
project: item.project,
day: item.day,
slot: item.slot,
key: item.booking.id,
dateText: formatDateText(item.day.date),
statusText: bookingStatusText(status),
canCancel: status == 'pending' || status == 'confirmed'
} as BookingDetail)
}
return result
}
export function emptyDay(): Day {
return { date: '', status: '', slots: [] } as Day
}
export function emptySlot(): Slot {
return { id: '', start_time: '', status: '' } as Slot
}
-101
View File
@@ -1,101 +0,0 @@
import { BookingDetail, Day, Draft, Project, Slot } from './types.uts'
const DRAFT_KEY = 'sf_booking_draft'
const LAST_BOOKING_KEY = 'sf_last_booking'
export function emptyDraft(): Draft {
return {
project: null,
day: null,
date: '',
slot: null,
phone: ''
} as Draft
}
export function getDraft(): Draft {
const raw = uni.getStorageSync(DRAFT_KEY)
if (raw == null || raw == '') return emptyDraft()
const draft = raw as Draft
if (draft.phone == null) draft.phone = ''
if (draft.date == null) draft.date = ''
return draft
}
export function saveDraft(draft: Draft): Draft {
uni.setStorageSync(DRAFT_KEY, draft)
return draft
}
export function clearDraft(): void {
const phone = getDraft().phone
uni.removeStorageSync(DRAFT_KEY)
if (phone.length > 0) {
saveDraft({
project: null,
day: null,
date: '',
slot: null,
phone
} as Draft)
}
}
export function setProject(project: Project): Draft {
const draft = getDraft()
const next = {
project: {
id: project.id,
category_id: project.category_id,
name: project.name,
subtitle: project.subtitle,
price_text: project.price_text,
image: project.image,
image_key: project.image_key,
sortIndex: project.sortIndex
} as Project,
day: null,
date: '',
slot: null,
phone: draft.phone
} as Draft
return saveDraft(next)
}
export function setSchedule(day: Day, slot: Slot): Draft {
const draft = getDraft()
const nextDay = {
date: day.date,
status: day.status,
slots: []
} as Day
const nextSlot = {
id: slot.id,
start_time: slot.start_time,
status: slot.status
} as Slot
const next = {
project: draft.project,
day: nextDay,
date: nextDay.date,
slot: nextSlot,
phone: draft.phone
} as Draft
return saveDraft(next)
}
export function setPhone(phone: string): Draft {
const draft = getDraft()
draft.phone = phone
return saveDraft(draft)
}
export function setLastBooking(detail: BookingDetail): void {
uni.setStorageSync(LAST_BOOKING_KEY, detail)
}
export function getLastBooking(): BookingDetail | null {
const raw = uni.getStorageSync(LAST_BOOKING_KEY)
if (raw == null || raw == '') return null
return raw as BookingDetail
}
-11
View File
@@ -1,11 +0,0 @@
import { QuickAction } from './types.uts'
export const PROJECT_PLACEHOLDER_IMAGE = '/static/images/project-placeholder.svg'
export function quickActions(): QuickAction[] {
return [
{ key: 'booking', icon: '/static/icons/ico_book.svg', title: '预约时间', subtitle: '选项目 约时间' },
{ key: 'orders', icon: '/static/icons/ico_orders.svg', title: '我的预约', subtitle: '查看预约记录' },
{ key: 'price-query', icon: '/static/icons/ico_book.svg', title: '价格查询', subtitle: '查看项目参考价' }
]
}
-63
View File
@@ -3,66 +3,3 @@ export type User = {
openid: string
phone: any | null
}
export type Project = {
id: string
category_id: string
name: string
subtitle: string
price_text: string
image?: string
image_key?: string
sortIndex?: number
}
export type Category = {
id: string
name: string
icon?: string
image_key?: string
projects: Project[]
}
export type Day = {
date: string
status?: string
slots: Slot[]
}
export type Slot = {
id: string
start_time: string
status: string
statusText?: string
}
export type Draft = {
project: Project | null
day: Day | null
date: string
slot: Slot | null
phone: string
}
export type Booking = {
id: string
status: string
}
export type BookingDetail = {
booking: Booking
project: Project
day: Day
slot: Slot
key?: string
dateText?: string
statusText?: string
canCancel?: boolean
}
export type QuickAction = {
key: string
title: string
subtitle: string
icon: string
}