chore: remove legacy booking flows and use local admin auth
Build and push backend image / backend-image (push) Successful in 52s
Build and push backend image / backend-image (push) Successful in 52s
This commit is contained in:
+3
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,13 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const testAdminSessionSecret = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
AdminAuthMode: config.AdminAuthModeOIDC,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
OIDCIssuer: "https://auth.example.com/oidc",
|
||||
OIDCClientID: "test-client-id",
|
||||
OIDCClientSecret: "test-client-secret",
|
||||
OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback",
|
||||
AdminUsername: "test-admin",
|
||||
AdminPassword: "test-password",
|
||||
AdminSessionSecret: testAdminSessionSecret,
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
@@ -49,8 +48,8 @@ func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
|
||||
}
|
||||
|
||||
cookie := adminSessionCookieHeader(config.Config{
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
}, adminUser{Subject: "logto-user-1", DisplayName: "王医生"})
|
||||
AdminSessionSecret: testAdminSessionSecret,
|
||||
}, adminUser{Subject: "test-admin", DisplayName: "王医生"})
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
@@ -90,41 +89,21 @@ func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
|
||||
t.Fatalf("price inquiries page rendered removed title or ID column: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "王医生") {
|
||||
t.Fatalf("price inquiries page did not render OIDC user name: %s", body)
|
||||
t.Fatalf("price inquiries page did not render admin name: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "/admin/phones") || strings.Contains(body, "/admin/booking-projects") || strings.Contains(body, "/admin/project-config") {
|
||||
t.Fatalf("price inquiries page should not render removed module links: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "修改密码") || !strings.Contains(body, "https://auth.example.com/account/password?redirect=https%3A%2F%2Fgh.yqbmb.com%2Fadmin%2Fprice-inquiries&show_success=true") {
|
||||
t.Fatalf("price inquiries page did not render Logto password change link: %s", body)
|
||||
if strings.Contains(body, "修改密码") || strings.Contains(body, "OIDC") {
|
||||
t.Fatalf("price inquiries page rendered removed OIDC controls: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminNoneModeAllowsPagesAndShowsDisabledAuthName(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if !strings.Contains(body, "免登录管理员") || !strings.Contains(body, "价格咨询派单") {
|
||||
t.Fatalf("none mode page did not render expected admin state: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginPageRendersOIDCEntry(t *testing.T) {
|
||||
func TestAdminLoginPageRendersCredentialForm(t *testing.T) {
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
AdminAuthMode: config.AdminAuthModeOIDC,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
OIDCIssuer: "https://auth.example.com/oidc",
|
||||
OIDCClientID: "test-client-id",
|
||||
OIDCClientSecret: "test-client-secret",
|
||||
OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback",
|
||||
AdminUsername: "operator",
|
||||
AdminPassword: "local-password",
|
||||
AdminSessionSecret: testAdminSessionSecret,
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
@@ -136,11 +115,83 @@ func TestAdminLoginPageRendersOIDCEntry(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if !strings.Contains(body, "授权登录") || !strings.Contains(body, "/admin/oidc/start?next=%2Fadmin%2Fprice-inquiries") {
|
||||
t.Fatalf("login page did not render OIDC login entry: %s", body)
|
||||
for _, expected := range []string{`action="/admin/login"`, `name="username"`, `name="password"`, `name="next"`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("login page missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `name="username"`) || strings.Contains(body, `name="password"`) {
|
||||
t.Fatalf("login page should not render local credential form: %s", body)
|
||||
if strings.Contains(body, "OIDC") || strings.Contains(body, "/admin/oidc/") {
|
||||
t.Fatalf("login page rendered removed OIDC controls: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginAcceptsCredentialsAndSetsSession(t *testing.T) {
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
AdminUsername: "operator",
|
||||
AdminPassword: "local-password",
|
||||
AdminSessionSecret: testAdminSessionSecret,
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
invalid := url.Values{"username": {"operator"}, "password": {"wrong"}, "next": {"/admin"}}
|
||||
resp := doRequest(t, app, http.MethodPost, "/admin/login", invalid.Encode(), map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
body := readBody(t, resp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(body, "用户名或密码错误") {
|
||||
t.Fatalf("invalid login status/body = %d/%s, want 401 with generic error", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("Set-Cookie") != "" {
|
||||
t.Fatalf("invalid login issued a cookie: %s", resp.Header.Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
valid := url.Values{"username": {"operator"}, "password": {"local-password"}, "next": {"/admin/price-inquiries"}}
|
||||
resp = doRequest(t, app, http.MethodPost, "/admin/login", valid.Encode(), map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/admin/price-inquiries" {
|
||||
t.Fatalf("valid login status/location = %d/%q, want redirect to admin page", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
var session *http.Cookie
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == adminSessionCookie {
|
||||
session = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if session == nil || !session.HttpOnly || session.Path != "/admin" || session.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("invalid admin session cookie: %#v", session)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
|
||||
"Cookie": session.Name + "=" + session.Value,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authenticated page status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginRateLimit(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
body := url.Values{"username": {"wrong"}, "password": {"wrong"}}.Encode()
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
resp := doRequest(t, app, http.MethodPost, "/admin/login", body, map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
resp.Body.Close()
|
||||
want := http.StatusUnauthorized
|
||||
if attempt == 6 {
|
||||
want = http.StatusTooManyRequests
|
||||
}
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("attempt %d status = %d, want %d", attempt, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +205,27 @@ func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) {
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{method: http.MethodGet, path: "/api/app/bootstrap"},
|
||||
{method: http.MethodGet, path: "/api/projects"},
|
||||
{method: http.MethodGet, path: "/api/bookings"},
|
||||
{method: http.MethodPost, path: "/api/bookings", body: "{}"},
|
||||
{method: http.MethodGet, path: "/api/bookings/old"},
|
||||
{method: http.MethodPatch, path: "/api/bookings/old/cancel", body: "{}"},
|
||||
{method: http.MethodGet, path: "/admin/oidc/start"},
|
||||
{method: http.MethodGet, path: "/admin/oidc/callback"},
|
||||
{method: http.MethodGet, path: "/admin/phones"},
|
||||
{method: http.MethodGet, path: "/admin/categories"},
|
||||
{method: http.MethodPost, path: "/admin/categories", body: "{}"},
|
||||
{method: http.MethodGet, path: "/admin/projects"},
|
||||
{method: http.MethodPost, path: "/admin/projects", body: "{}"},
|
||||
{method: http.MethodGet, path: "/admin/schedules"},
|
||||
{method: http.MethodPost, path: "/admin/schedules/days", body: "{}"},
|
||||
{method: http.MethodPatch, path: "/admin/schedules/days/day", body: "{}"},
|
||||
{method: http.MethodPost, path: "/admin/schedules/slots", body: "{}"},
|
||||
{method: http.MethodPatch, path: "/admin/schedules/slots/slot", body: "{}"},
|
||||
{method: http.MethodGet, path: "/admin/bookings"},
|
||||
{method: http.MethodPatch, path: "/admin/bookings/booking/status", body: "{}"},
|
||||
{method: http.MethodGet, path: "/admin/reports/bookings"},
|
||||
{method: http.MethodGet, path: "/admin/booking-projects"},
|
||||
{method: http.MethodGet, path: "/admin/project-config"},
|
||||
{method: http.MethodPost, path: "/admin/project-config/projects", body: "id=project_fresh_clean"},
|
||||
@@ -162,11 +233,12 @@ func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) {
|
||||
resp := doRequest(t, app, route.method, route.path, route.body, map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, readBody(t, resp))
|
||||
t.Fatalf("%s %s status = %d, want %d: %s", route.method, route.path, resp.StatusCode, http.StatusNotFound, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,53 +708,19 @@ func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber.
|
||||
log := logrus.New()
|
||||
log.SetOutput(io.Discard)
|
||||
svc := service.New(database, log)
|
||||
if err := svc.Seed(ctx); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("seed: %v", err)
|
||||
cfg := cfgOverride
|
||||
cfg.AllowOrigins = "*"
|
||||
if cfg.WeChatAPIBase == "" {
|
||||
cfg.WeChatAPIBase = "https://api.weixin.qq.com"
|
||||
}
|
||||
session, err := svc.Login(ctx, "admin_test")
|
||||
if err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("login seeded user: %v", err)
|
||||
if cfg.AdminUsername == "" {
|
||||
cfg.AdminUsername = "test-admin"
|
||||
}
|
||||
if _, err := svc.BindPhone(ctx, session.User.Openid, "13900001111"); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("bind seeded phone: %v", err)
|
||||
if cfg.AdminPassword == "" {
|
||||
cfg.AdminPassword = "test-password"
|
||||
}
|
||||
_, err = svc.CreateBooking(ctx, service.CreateBookingRequest{
|
||||
OpenID: session.User.Openid,
|
||||
ProjectID: "project_fresh_clean",
|
||||
Date: time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
|
||||
StartTime: "09:30",
|
||||
Phone: "13900001111",
|
||||
})
|
||||
if err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("create seeded booking: %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
AllowOrigins: "*",
|
||||
UploadDir: t.TempDir(),
|
||||
WeChatAppID: cfgOverride.WeChatAppID,
|
||||
WeChatAppSecret: cfgOverride.WeChatAppSecret,
|
||||
WeChatAPIBase: cfgOverride.WeChatAPIBase,
|
||||
AdminAuthMode: config.AdminAuthModeNone,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
AdminPasswordChangeRedirectURL: cfgOverride.AdminPasswordChangeRedirectURL,
|
||||
OIDCIssuer: cfgOverride.OIDCIssuer,
|
||||
OIDCClientID: cfgOverride.OIDCClientID,
|
||||
OIDCClientSecret: cfgOverride.OIDCClientSecret,
|
||||
OIDCRedirectURI: cfgOverride.OIDCRedirectURI,
|
||||
OIDCScopes: cfgOverride.OIDCScopes,
|
||||
}
|
||||
if cfgOverride.AdminAuthMode != "" {
|
||||
cfg.AdminAuthMode = cfgOverride.AdminAuthMode
|
||||
}
|
||||
if cfgOverride.AdminSessionSecret != "" {
|
||||
cfg.AdminSessionSecret = cfgOverride.AdminSessionSecret
|
||||
}
|
||||
if cfg.AdminPasswordChangeRedirectURL == "" {
|
||||
cfg.AdminPasswordChangeRedirectURL = "https://gh.yqbmb.com/admin/price-inquiries"
|
||||
if cfg.AdminSessionSecret == "" {
|
||||
cfg.AdminSessionSecret = testAdminSessionSecret
|
||||
}
|
||||
return New(cfg, svc, log), func() { database.Close() }
|
||||
}
|
||||
@@ -738,7 +776,10 @@ func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) st
|
||||
|
||||
func loginAdmin(t *testing.T, app *fiber.App) string {
|
||||
t.Helper()
|
||||
return ""
|
||||
return adminSessionCookieHeader(config.Config{AdminSessionSecret: testAdminSessionSecret}, adminUser{
|
||||
Subject: "test-admin",
|
||||
DisplayName: "test-admin",
|
||||
})
|
||||
}
|
||||
|
||||
func adminSessionCookieHeader(cfg config.Config, user adminUser) string {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
|
||||
@@ -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 *;
|
||||
@@ -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 = ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '查看项目参考价' }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user