Initial commit
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var adminTemplateFS embed.FS
|
||||
|
||||
const (
|
||||
adminSessionCookie = "smilefirst_admin"
|
||||
adminOIDCCookie = "smilefirst_oidc"
|
||||
defaultAdminPage = "/admin/price-inquiries"
|
||||
adminUserLocalKey = "adminUser"
|
||||
defaultAdminPageSize = 50
|
||||
)
|
||||
|
||||
type adminUI struct {
|
||||
cfg config.Config
|
||||
svc *service.Service
|
||||
templates *template.Template
|
||||
oidcMu sync.Mutex
|
||||
oidcProvider *oidc.Provider
|
||||
oauth2Config *oauth2.Config
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
type adminPageData struct {
|
||||
PageTitle string
|
||||
Active string
|
||||
Username string
|
||||
Next string
|
||||
Message string
|
||||
Error string
|
||||
OIDCLoginURL string
|
||||
PasswordChangeURL string
|
||||
HomeHeroImage string
|
||||
Categories []service.CategoryWithProjects
|
||||
PhoneRecords []service.AdminPhoneRecord
|
||||
BookingRecords []service.AdminBookingRecord
|
||||
PriceInquiryRecords []service.AdminPriceInquiryRecord
|
||||
PhoneFilter string
|
||||
AreaFilter string
|
||||
StatusFilter string
|
||||
TimeFromFilter string
|
||||
TimeToFilter string
|
||||
HasPriceInquiryFilter bool
|
||||
PriceInquiryTotal int64
|
||||
PriceInquiryPage int
|
||||
PriceInquiryPageSize int
|
||||
PriceInquiryTotalPages int
|
||||
PriceInquiryHasPreviousPage bool
|
||||
PriceInquiryHasNextPage bool
|
||||
PriceInquiryPreviousURL string
|
||||
PriceInquiryNextURL string
|
||||
PriceInquiryPageSizeOptions []adminPageSizeOption
|
||||
}
|
||||
|
||||
type adminPageSizeOption struct {
|
||||
Value int
|
||||
Selected bool
|
||||
}
|
||||
|
||||
func newAdminUI(cfg config.Config, svc *service.Service) *adminUI {
|
||||
tmpl := template.Must(template.ParseFS(adminTemplateFS, "templates/*.html"))
|
||||
return &adminUI{cfg: cfg, svc: svc, templates: tmpl}
|
||||
}
|
||||
|
||||
func (a *adminUI) registerPublic(app *fiber.App) {
|
||||
app.Get("/admin/login", a.loginPage)
|
||||
app.Post("/admin/login", a.loginRedirect)
|
||||
app.Get("/admin/oidc/start", a.oidcStart)
|
||||
app.Get("/admin/oidc/callback", a.oidcCallback)
|
||||
app.Post("/admin/logout", a.logout)
|
||||
}
|
||||
|
||||
func (a *adminUI) registerPages(admin fiber.Router) {
|
||||
admin.Get("/", func(c fiber.Ctx) error {
|
||||
return c.Redirect().To(defaultAdminPage)
|
||||
})
|
||||
admin.Get("/price-inquiries", a.priceInquiriesPage)
|
||||
admin.Post("/price-inquiries/settings", a.savePriceInquirySettings)
|
||||
}
|
||||
|
||||
func (a *adminUI) requireLogin(c fiber.Ctx) error {
|
||||
if a.cfg.AdminAuthDisabled() {
|
||||
c.Locals(adminUserLocalKey, adminUser{
|
||||
Subject: "auth:none",
|
||||
DisplayName: "免登录管理员",
|
||||
})
|
||||
return c.Next()
|
||||
}
|
||||
if user, ok := a.validSession(c.Cookies(adminSessionCookie)); ok {
|
||||
c.Locals(adminUserLocalKey, user)
|
||||
return c.Next()
|
||||
}
|
||||
if c.Method() == fiber.MethodGet && wantsHTML(c) {
|
||||
next := url.QueryEscape(c.OriginalURL())
|
||||
return c.Redirect().To("/admin/login?next=" + next)
|
||||
}
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "admin login required")
|
||||
}
|
||||
|
||||
func (a *adminUI) loginPage(c fiber.Ctx) error {
|
||||
if a.cfg.AdminAuthDisabled() {
|
||||
return c.Redirect().To(defaultAdminPage)
|
||||
}
|
||||
if _, ok := a.validSession(c.Cookies(adminSessionCookie)); ok {
|
||||
return c.Redirect().To(safeAdminNext(c.Query("next")))
|
||||
}
|
||||
next := safeAdminNext(c.Query("next"))
|
||||
return a.render(c, "login.html", adminPageData{
|
||||
PageTitle: "后台登录",
|
||||
Next: next,
|
||||
Error: c.Query("error"),
|
||||
OIDCLoginURL: "/admin/oidc/start?next=" + url.QueryEscape(next),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adminUI) loginRedirect(c fiber.Ctx) error {
|
||||
if a.cfg.AdminAuthDisabled() {
|
||||
return c.Redirect().To(defaultAdminPage)
|
||||
}
|
||||
next := safeAdminNext(c.FormValue("next"))
|
||||
return c.Redirect().To("/admin/oidc/start?next=" + url.QueryEscape(next))
|
||||
}
|
||||
|
||||
func (a *adminUI) oidcStart(c fiber.Ctx) error {
|
||||
if !a.cfg.AdminOIDCLoginEnabled() {
|
||||
return c.Redirect().To(defaultAdminPage)
|
||||
}
|
||||
oauthConfig, _, _, err := a.oidcClient(c.Context())
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
state := adminOIDCState{
|
||||
State: randomHex(24),
|
||||
Nonce: randomHex(24),
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Next: safeAdminNext(c.Query("next")),
|
||||
Expires: time.Now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
expires := time.Unix(state.Expires, 0)
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: adminOIDCCookie,
|
||||
Value: a.signCookieValue(state),
|
||||
Path: "/admin",
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
Expires: expires,
|
||||
HTTPOnly: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
return c.Redirect().To(oauthConfig.AuthCodeURL(
|
||||
state.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.S256ChallengeOption(state.Verifier),
|
||||
oidc.Nonce(state.Nonce),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *adminUI) oidcCallback(c fiber.Ctx) error {
|
||||
if !a.cfg.AdminOIDCLoginEnabled() {
|
||||
return c.Redirect().To(defaultAdminPage)
|
||||
}
|
||||
state, ok := a.validOIDCState(c.Cookies(adminOIDCCookie))
|
||||
a.clearOIDCStateCookie(c)
|
||||
if !ok || !constantTimeEqual(state.State, c.Query("state")) {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权状态已失效,请重新登录"))
|
||||
}
|
||||
if errText := strings.TrimSpace(c.Query("error")); errText != "" {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape(errText))
|
||||
}
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
if code == "" {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权回调缺少 code"))
|
||||
}
|
||||
oauthConfig, provider, verifier, err := a.oidcClient(c.Context())
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
token, err := oauthConfig.Exchange(c.Context(), code, oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权登录失败,请重试"))
|
||||
}
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权结果缺少 ID Token"))
|
||||
}
|
||||
idToken, err := verifier.Verify(c.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份校验失败"))
|
||||
}
|
||||
if !constantTimeEqual(idToken.Nonce, state.Nonce) {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权 nonce 校验失败"))
|
||||
}
|
||||
user, err := a.adminUserFromToken(c.Context(), provider, token, idToken)
|
||||
if err != nil {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权用户信息读取失败"))
|
||||
}
|
||||
expires := time.Now().Add(8 * time.Hour)
|
||||
if idToken.Expiry.Before(expires) {
|
||||
expires = idToken.Expiry
|
||||
}
|
||||
if time.Until(expires) <= 0 {
|
||||
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份已过期,请重新登录"))
|
||||
}
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: a.signSession(user, expires),
|
||||
Path: "/admin",
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
Expires: expires,
|
||||
HTTPOnly: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
return c.Redirect().To(state.Next)
|
||||
}
|
||||
|
||||
func (a *adminUI) logout(c fiber.Ctx) error {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
MaxAge: -1,
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
HTTPOnly: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
return c.Redirect().To("/admin/login")
|
||||
}
|
||||
|
||||
func (a *adminUI) priceInquiriesPage(c fiber.Ctx) error {
|
||||
filter := service.AdminPriceInquiryFilter{
|
||||
Area: strings.TrimSpace(c.Query("area")),
|
||||
Phone: strings.TrimSpace(c.Query("phone")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
TimeFrom: strings.TrimSpace(c.Query("time_from")),
|
||||
TimeTo: strings.TrimSpace(c.Query("time_to")),
|
||||
}
|
||||
if !validAdminPriceInquiryStatusFilter(filter.Status) {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "咨询状态筛选无效")
|
||||
}
|
||||
page, pageSize, err := parseAdminPriceInquiryQueryPagination(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := a.svc.AdminPriceInquiryRecordsPage(c.Context(), filter, page, pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.render(c, "price_inquiries.html", adminPageData{
|
||||
PageTitle: "价格咨询派单",
|
||||
Active: "price-inquiries",
|
||||
PriceInquiryRecords: result.Records,
|
||||
AreaFilter: filter.Area,
|
||||
PhoneFilter: filter.Phone,
|
||||
StatusFilter: filter.Status,
|
||||
TimeFromFilter: filter.TimeFrom,
|
||||
TimeToFilter: filter.TimeTo,
|
||||
HasPriceInquiryFilter: filter.Area != "" || filter.Phone != "" || filter.Status != "" || filter.TimeFrom != "" || filter.TimeTo != "",
|
||||
PriceInquiryTotal: result.Total,
|
||||
PriceInquiryPage: result.Page,
|
||||
PriceInquiryPageSize: result.PageSize,
|
||||
PriceInquiryTotalPages: result.TotalPages,
|
||||
PriceInquiryHasPreviousPage: result.HasPreviousPage,
|
||||
PriceInquiryHasNextPage: result.HasNextPage,
|
||||
PriceInquiryPreviousURL: priceInquiriesURL(filter, result.PreviousPage, result.PageSize, "", ""),
|
||||
PriceInquiryNextURL: priceInquiriesURL(filter, result.NextPage, result.PageSize, "", ""),
|
||||
PriceInquiryPageSizeOptions: adminPageSizeOptions(result.PageSize),
|
||||
Message: c.Query("message"),
|
||||
Error: c.Query("error"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adminUI) savePriceInquirySettings(c fiber.Ctx) error {
|
||||
id := strings.TrimSpace(c.FormValue("id"))
|
||||
status := strings.TrimSpace(c.FormValue("status"))
|
||||
filter := service.AdminPriceInquiryFilter{
|
||||
Area: strings.TrimSpace(c.FormValue("area")),
|
||||
Phone: strings.TrimSpace(c.FormValue("phone")),
|
||||
Status: strings.TrimSpace(c.FormValue("status_filter")),
|
||||
TimeFrom: strings.TrimSpace(c.FormValue("time_from")),
|
||||
TimeTo: strings.TrimSpace(c.FormValue("time_to")),
|
||||
}
|
||||
page, pageSize, err := parseAdminPriceInquiryFormPagination(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !validAdminPriceInquiryStatusFilter(filter.Status) {
|
||||
return redirectPriceInquiries(c, service.AdminPriceInquiryFilter{}, page, pageSize, "error", "咨询状态筛选无效")
|
||||
}
|
||||
if id == "" {
|
||||
return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询单不存在")
|
||||
}
|
||||
switch status {
|
||||
case "pending", "assigned", "completed":
|
||||
default:
|
||||
return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询状态无效")
|
||||
}
|
||||
if _, err := a.svc.Q.UpdatePriceInquiry(c.Context(), db.UpdatePriceInquiryParams{
|
||||
ID: id,
|
||||
Status: status,
|
||||
Remark: strings.TrimSpace(c.FormValue("remark")),
|
||||
}); err != nil {
|
||||
return redirectPriceInquiries(c, filter, page, pageSize, "error", fmt.Sprintf("咨询设置保存失败:%v", err))
|
||||
}
|
||||
return redirectPriceInquiries(c, filter, page, pageSize, "message", "咨询设置已保存")
|
||||
}
|
||||
|
||||
func validAdminPriceInquiryStatusFilter(status string) bool {
|
||||
switch status {
|
||||
case "", "pending", "assigned", "completed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseAdminPriceInquiryQueryPagination(c fiber.Ctx) (int, int, error) {
|
||||
return parseAdminPriceInquiryPagination(c.Query("page"), c.Query("page_size"))
|
||||
}
|
||||
|
||||
func parseAdminPriceInquiryFormPagination(c fiber.Ctx) (int, int, error) {
|
||||
return parseAdminPriceInquiryPagination(c.FormValue("page"), c.FormValue("page_size"))
|
||||
}
|
||||
|
||||
func parseAdminPriceInquiryPagination(pageRaw, pageSizeRaw string) (int, int, error) {
|
||||
page, err := positiveIntOrDefault(pageRaw, 1)
|
||||
if err != nil {
|
||||
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "分页页码无效")
|
||||
}
|
||||
pageSize, err := positiveIntOrDefault(pageSizeRaw, defaultAdminPageSize)
|
||||
if err != nil {
|
||||
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效")
|
||||
}
|
||||
if !validAdminPageSize(pageSize) {
|
||||
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效")
|
||||
}
|
||||
return page, pageSize, nil
|
||||
}
|
||||
|
||||
func positiveIntOrDefault(raw string, fallback int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("invalid positive integer %q", raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validAdminPageSize(pageSize int) bool {
|
||||
switch pageSize {
|
||||
case 20, 50, 100, 200:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func adminPageSizeOptions(selected int) []adminPageSizeOption {
|
||||
options := []int{20, 50, 100, 200}
|
||||
result := make([]adminPageSizeOption, 0, len(options))
|
||||
for _, option := range options {
|
||||
result = append(result, adminPageSizeOption{
|
||||
Value: option,
|
||||
Selected: option == selected,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *adminUI) render(c fiber.Ctx, name string, data adminPageData) error {
|
||||
if user, ok := currentAdminUser(c); ok {
|
||||
data.Username = user.DisplayName
|
||||
}
|
||||
data.PasswordChangeURL = a.passwordChangeURL()
|
||||
var buf bytes.Buffer
|
||||
if err := a.templates.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Type("html", "utf-8")
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
type adminUser struct {
|
||||
Subject string `json:"sub"`
|
||||
DisplayName string `json:"name"`
|
||||
}
|
||||
|
||||
type adminSession struct {
|
||||
Subject string `json:"sub"`
|
||||
DisplayName string `json:"name"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type adminOIDCState struct {
|
||||
State string `json:"state"`
|
||||
Nonce string `json:"nonce"`
|
||||
Verifier string `json:"verifier"`
|
||||
Next string `json:"next"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type adminOIDCClaims struct {
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}
|
||||
|
||||
func (a *adminUI) signSession(user adminUser, expires time.Time) string {
|
||||
return a.signCookieValue(adminSession{
|
||||
Subject: user.Subject,
|
||||
DisplayName: user.DisplayName,
|
||||
Expires: expires.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adminUI) validSession(token string) (adminUser, bool) {
|
||||
var session adminSession
|
||||
if !a.verifyCookieValue(token, &session) {
|
||||
return adminUser{}, false
|
||||
}
|
||||
if strings.TrimSpace(session.Subject) == "" || time.Now().Unix() >= session.Expires {
|
||||
return adminUser{}, false
|
||||
}
|
||||
displayName := strings.TrimSpace(session.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = session.Subject
|
||||
}
|
||||
return adminUser{Subject: session.Subject, DisplayName: displayName}, true
|
||||
}
|
||||
|
||||
func (a *adminUI) validOIDCState(token string) (adminOIDCState, bool) {
|
||||
var state adminOIDCState
|
||||
if !a.verifyCookieValue(token, &state) {
|
||||
return adminOIDCState{}, false
|
||||
}
|
||||
if strings.TrimSpace(state.State) == "" || strings.TrimSpace(state.Nonce) == "" || strings.TrimSpace(state.Verifier) == "" {
|
||||
return adminOIDCState{}, false
|
||||
}
|
||||
if time.Now().Unix() >= state.Expires {
|
||||
return adminOIDCState{}, false
|
||||
}
|
||||
state.Next = safeAdminNext(state.Next)
|
||||
return state, true
|
||||
}
|
||||
|
||||
func (a *adminUI) signCookieValue(value any) string {
|
||||
payloadBytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
sig := a.sessionMAC(payload)
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func (a *adminUI) verifyCookieValue(token string, value any) bool {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !hmac.Equal(signature, a.sessionMAC(parts[0])) {
|
||||
return false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal(payloadBytes, value) == nil
|
||||
}
|
||||
|
||||
func (a *adminUI) sessionMAC(payload string) []byte {
|
||||
secret := a.cfg.AdminSessionSecret
|
||||
if secret == "" {
|
||||
secret = a.cfg.OIDCClientSecret
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (a *adminUI) oidcClient(ctx context.Context) (*oauth2.Config, *oidc.Provider, *oidc.IDTokenVerifier, error) {
|
||||
a.oidcMu.Lock()
|
||||
defer a.oidcMu.Unlock()
|
||||
if a.oauth2Config != nil && a.oidcProvider != nil && a.oidcVerifier != nil {
|
||||
return a.oauth2Config, a.oidcProvider, a.oidcVerifier, nil
|
||||
}
|
||||
provider, err := oidc.NewProvider(ctx, a.cfg.OIDCIssuer)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("初始化 OIDC Provider 失败:%w", err)
|
||||
}
|
||||
oauthConfig := &oauth2.Config{
|
||||
ClientID: a.cfg.OIDCClientID,
|
||||
ClientSecret: a.cfg.OIDCClientSecret,
|
||||
RedirectURL: a.cfg.OIDCRedirectURI,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: oidcScopes(a.cfg.OIDCScopes),
|
||||
}
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: a.cfg.OIDCClientID})
|
||||
a.oidcProvider = provider
|
||||
a.oauth2Config = oauthConfig
|
||||
a.oidcVerifier = verifier
|
||||
return oauthConfig, provider, verifier, nil
|
||||
}
|
||||
|
||||
func (a *adminUI) adminUserFromToken(ctx context.Context, provider *oidc.Provider, token *oauth2.Token, idToken *oidc.IDToken) (adminUser, error) {
|
||||
claims := adminOIDCClaims{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return adminUser{}, err
|
||||
}
|
||||
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
||||
if err == nil {
|
||||
_ = userInfo.Claims(&claims)
|
||||
}
|
||||
subject := strings.TrimSpace(idToken.Subject)
|
||||
displayName := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Email, claims.PhoneNumber, subject)
|
||||
if displayName == "" {
|
||||
displayName = "管理员"
|
||||
}
|
||||
return adminUser{Subject: subject, DisplayName: displayName}, nil
|
||||
}
|
||||
|
||||
func currentAdminUser(c fiber.Ctx) (adminUser, bool) {
|
||||
value := c.Locals(adminUserLocalKey)
|
||||
user, ok := value.(adminUser)
|
||||
if !ok || strings.TrimSpace(user.DisplayName) == "" {
|
||||
return adminUser{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func (a *adminUI) clearOIDCStateCookie(c fiber.Ctx) {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: adminOIDCCookie,
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
MaxAge: -1,
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
HTTPOnly: true,
|
||||
SameSite: "Lax",
|
||||
})
|
||||
}
|
||||
|
||||
func oidcScopes(value string) []string {
|
||||
seen := map[string]bool{"openid": true}
|
||||
scopes := []string{"openid"}
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
for _, part := range parts {
|
||||
scope := strings.TrimSpace(part)
|
||||
if scope != "" && !seen[scope] {
|
||||
scopes = append(scopes, scope)
|
||||
seen[scope] = true
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func wantsHTML(c fiber.Ctx) bool {
|
||||
accept := c.Get(fiber.HeaderAccept)
|
||||
return accept == "" || strings.Contains(accept, "text/html")
|
||||
}
|
||||
|
||||
func safeAdminNext(next string) string {
|
||||
next = strings.TrimSpace(next)
|
||||
if strings.HasPrefix(next, "/admin") && !strings.HasPrefix(next, "/admin/login") {
|
||||
return next
|
||||
}
|
||||
return defaultAdminPage
|
||||
}
|
||||
|
||||
func (a *adminUI) passwordChangeURL() string {
|
||||
if !a.cfg.AdminOIDCLoginEnabled() {
|
||||
return ""
|
||||
}
|
||||
issuer := strings.TrimSpace(a.cfg.OIDCIssuer)
|
||||
redirectURL := strings.TrimSpace(a.cfg.AdminPasswordChangeRedirectURL)
|
||||
if issuer == "" || redirectURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
issuerPath := strings.TrimRight(u.Path, "/")
|
||||
if strings.HasSuffix(issuerPath, "/oidc") {
|
||||
issuerPath = strings.TrimSuffix(issuerPath, "/oidc")
|
||||
}
|
||||
u.Path = strings.TrimRight(issuerPath, "/") + "/account/password"
|
||||
q := u.Query()
|
||||
q.Set("redirect", redirectURL)
|
||||
q.Set("show_success", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func redirectPriceInquiries(c fiber.Ctx, filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) error {
|
||||
return c.Redirect().To(priceInquiriesURL(filter, page, pageSize, key, message))
|
||||
}
|
||||
|
||||
func priceInquiriesURL(filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) string {
|
||||
query := url.Values{}
|
||||
if key != "" {
|
||||
query.Set(key, message)
|
||||
}
|
||||
if filter.Area != "" {
|
||||
query.Set("area", filter.Area)
|
||||
}
|
||||
if filter.Phone != "" {
|
||||
query.Set("phone", filter.Phone)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
query.Set("status", filter.Status)
|
||||
}
|
||||
if filter.TimeFrom != "" {
|
||||
query.Set("time_from", filter.TimeFrom)
|
||||
}
|
||||
if filter.TimeTo != "" {
|
||||
query.Set("time_to", filter.TimeTo)
|
||||
}
|
||||
if page > 1 {
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if pageSize != defaultAdminPageSize {
|
||||
query.Set("page_size", strconv.Itoa(pageSize))
|
||||
}
|
||||
encoded := query.Encode()
|
||||
if encoded == "" {
|
||||
return "/admin/price-inquiries"
|
||||
}
|
||||
return "/admin/price-inquiries?" + encoded
|
||||
}
|
||||
func randomHex(size int) string {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
|
||||
"github.com/sirupsen/logrus"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestAdminLoginProtectsPagesAndRendersPriceInquiries(t *testing.T) {
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
AdminAuthMode: config.AdminAuthModeOIDC,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
OIDCIssuer: "https://auth.example.com/oidc",
|
||||
OIDCClientID: "test-client-id",
|
||||
OIDCClientSecret: "test-client-secret",
|
||||
OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback",
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
|
||||
}
|
||||
if location := resp.Header.Get("Location"); !strings.HasPrefix(location, "/admin/login?next=") {
|
||||
t.Fatalf("Location = %q, want login redirect", location)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/projects", "", map[string]string{"Accept": "application/json"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
cookie := adminSessionCookieHeader(config.Config{
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
}, adminUser{Subject: "logto-user-1", DisplayName: "王医生"})
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("admin root status = %d, want %d", resp.StatusCode, http.StatusSeeOther)
|
||||
}
|
||||
if location := resp.Header.Get("Location"); location != defaultAdminPage {
|
||||
t.Fatalf("admin root Location = %q, want %q", location, defaultAdminPage)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if !strings.Contains(body, "价格咨询派单") || !strings.Contains(body, "创建时间") || !strings.Contains(body, "最近提交时间") || !strings.Contains(body, "备注") || !strings.Contains(body, "设置") {
|
||||
t.Fatalf("price inquiries page did not render expected columns: %s", body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"共 0 条咨询,第 1 / 1 页",
|
||||
`<select id="page_size" name="page_size">`,
|
||||
`<option value="50" selected>50 条/页</option>`,
|
||||
`<span class="button secondary small disabled">上一页</span>`,
|
||||
`<span class="button secondary small disabled">下一页</span>`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("price inquiries page missing pagination element %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "已授权手机号提交的地区和咨询项目") || strings.Contains(body, "咨询单 ID") {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
AdminAuthMode: config.AdminAuthModeOIDC,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
OIDCIssuer: "https://auth.example.com/oidc",
|
||||
OIDCClientID: "test-client-id",
|
||||
OIDCClientSecret: "test-client-secret",
|
||||
OIDCRedirectURI: "http://127.0.0.1:9800/admin/oidc/callback",
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodGet, "/admin/login?next=%2Fadmin%2Fprice-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, "/admin/oidc/start?next=%2Fadmin%2Fprice-inquiries") {
|
||||
t.Fatalf("login page did not render OIDC login entry: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `name="username"`) || strings.Contains(body, `name="password"`) {
|
||||
t.Fatalf("login page should not render local credential form: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRemovedModuleRoutesReturnNotFound(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
cookie := loginAdmin(t, app)
|
||||
for _, route := range []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{method: http.MethodGet, path: "/admin/phones"},
|
||||
{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"},
|
||||
} {
|
||||
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",
|
||||
})
|
||||
defer 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceInquirySavesRegionAndAppearsInAdminDispatchList(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"price_inquiry_test"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
||||
}
|
||||
var session struct {
|
||||
User struct {
|
||||
Openid string `json:"openid"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900005555"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"牙齿种植"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
||||
}
|
||||
var createdInquiry struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &createdInquiry); err != nil {
|
||||
t.Fatalf("decode price inquiry: %v", err)
|
||||
}
|
||||
if createdInquiry.ID == "" || createdInquiry.Status != "pending" {
|
||||
t.Fatalf("price inquiry = %+v, want id and pending status", createdInquiry)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", `{"openid":"`+session.User.Openid+`","province":"上海市","city":"上海市","district":"浦东新区","project_name":"牙齿种植"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("duplicate price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
||||
}
|
||||
if !strings.Contains(body, `"updated_at":`) {
|
||||
t.Fatalf("duplicate price inquiry body = %s, want updated_at", body)
|
||||
}
|
||||
|
||||
cookie := loginAdmin(t, app)
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"13900005555",
|
||||
"牙齿种植",
|
||||
"上海市 上海市 浦东新区",
|
||||
"待派单",
|
||||
"<th>创建时间</th>",
|
||||
"<th>最近提交时间</th>",
|
||||
"<th>更新时间</th>",
|
||||
"<th>备注</th>",
|
||||
"<th>设置</th>",
|
||||
"共 1 条咨询",
|
||||
`name="phone" value="13900005555"`,
|
||||
`<select id="status" name="status">`,
|
||||
`<option value="" selected>全部状态</option>`,
|
||||
`name="time_from" value="2000-01-01" type="date"`,
|
||||
`name="time_to" value="2999-12-31" type="date"`,
|
||||
`class="status-pill status-pending">待派单</span>`,
|
||||
`class="button secondary small">设置</summary>`,
|
||||
`href="/admin/price-inquiries">全部</a>`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("admin price inquiry page missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<h1>价格咨询派单</h1>") || strings.Contains(body, "咨询单 ID") {
|
||||
t.Fatalf("admin price inquiry page rendered removed title or ID column: %s", body)
|
||||
}
|
||||
for _, removed := range []string{`name="created_from"`, `name="created_to"`, `name="updated_from"`, `name="updated_to"`} {
|
||||
if strings.Contains(body, removed) {
|
||||
t.Fatalf("admin price inquiry page rendered removed time filter %q: %s", removed, body)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, "<strong>13900005555</strong>") != 1 {
|
||||
t.Fatalf("admin price inquiry page rendered duplicate rows: %s", body)
|
||||
}
|
||||
|
||||
settings := url.Values{
|
||||
"id": {createdInquiry.ID},
|
||||
"area": {"浦东"},
|
||||
"phone": {"13900005555"},
|
||||
"status_filter": {"pending"},
|
||||
"time_from": {"2000-01-01"},
|
||||
"time_to": {"2999-12-31"},
|
||||
"status": {"assigned"},
|
||||
"remark": {"已派给浦东门诊"},
|
||||
}
|
||||
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("save price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
||||
}
|
||||
if location := resp.Header.Get("Location"); !strings.Contains(location, "area=%E6%B5%A6%E4%B8%9C") || !strings.Contains(location, "phone=13900005555") || !strings.Contains(location, "status=pending") || !strings.Contains(location, "time_from=2000-01-01") {
|
||||
t.Fatalf("save price inquiry settings Location = %q, want preserved filters", location)
|
||||
}
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东&phone=13900005555&status=assigned&time_from=2000-01-01&time_to=2999-12-31", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `class="status-pill status-assigned">已经派单</span>`) || !strings.Contains(body, "已派给浦东门诊") || !strings.Contains(body, `<option value="assigned" selected>已经派单</option>`) {
|
||||
t.Fatalf("saved price inquiry settings not rendered, status/body = %d/%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?status=completed", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin status-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
||||
t.Fatalf("admin status filter did not remove non-matching inquiries: %s", body)
|
||||
}
|
||||
|
||||
settings.Set("status", "completed")
|
||||
settings.Set("remark", "用户已联系,等待到院")
|
||||
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("overwrite price inquiry settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?area=浦东", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("updated admin price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
for _, expected := range []string{"已跟进", "用户已联系,等待到院"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("updated admin price inquiry page missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "已派给浦东门诊") {
|
||||
t.Fatalf("old price inquiry remark was not overwritten: %s", body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?phone=13999999999", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin phone-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
||||
t.Fatalf("admin phone filter did not remove non-matching inquiries: %s", body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?time_from=2999-01-01", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin time-filtered price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if strings.Contains(body, "13900005555") || !strings.Contains(body, "共 0 条咨询") {
|
||||
t.Fatalf("admin time filters did not remove out-of-range inquiries: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPriceInquiryPagination(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
var firstInquiryID string
|
||||
for i := 0; i < 21; i++ {
|
||||
phone := fmt.Sprintf("139100100%02d", i)
|
||||
id := createTestPriceInquiry(t, app, fmt.Sprintf("price_inquiry_page_%02d", i), phone)
|
||||
if i == 0 {
|
||||
firstInquiryID = id
|
||||
}
|
||||
}
|
||||
|
||||
cookie := loginAdmin(t, app)
|
||||
resp := doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page_size=20", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"共 21 条咨询,第 1 / 2 页",
|
||||
`<option value="20" selected>20 条/页</option>`,
|
||||
`<input type="hidden" name="page" value="1">`,
|
||||
`<input type="hidden" name="page_size" value="20">`,
|
||||
`href="/admin/price-inquiries?page=2&page_size=20">下一页</a>`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("first page missing pagination element %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, `class="button secondary small">设置</summary>`) != 20 {
|
||||
t.Fatalf("first page rendered wrong row count: %s", body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodGet, "/admin/price-inquiries?page=2&page_size=20", "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second page status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"共 21 条咨询,第 2 / 2 页",
|
||||
`href="/admin/price-inquiries?page_size=20">上一页</a>`,
|
||||
`<span class="button secondary small disabled">下一页</span>`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("second page missing pagination element %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, `class="button secondary small">设置</summary>`) != 1 {
|
||||
t.Fatalf("second page rendered wrong row count: %s", body)
|
||||
}
|
||||
|
||||
settings := url.Values{
|
||||
"id": {firstInquiryID},
|
||||
"status": {"assigned"},
|
||||
"remark": {"分页保留备注"},
|
||||
"page": {"2"},
|
||||
"page_size": {"20"},
|
||||
}
|
||||
resp = doRequest(t, app, http.MethodPost, "/admin/price-inquiries/settings", settings.Encode(), map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("save paged settings status = %d, want %d: %s", resp.StatusCode, http.StatusSeeOther, readBody(t, resp))
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if !strings.Contains(location, "page=2") || !strings.Contains(location, "page_size=20") || !strings.Contains(location, "message=") {
|
||||
t.Fatalf("save paged settings Location = %q, want preserved pagination", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPriceInquiryRejectsInvalidPagination(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
cookie := loginAdmin(t, app)
|
||||
for _, target := range []string{
|
||||
"/admin/price-inquiries?page=0",
|
||||
"/admin/price-inquiries?page=abc",
|
||||
"/admin/price-inquiries?page_size=10",
|
||||
"/admin/price-inquiries?page_size=abc",
|
||||
} {
|
||||
resp := doRequest(t, app, http.MethodGet, target, "", map[string]string{
|
||||
"Cookie": cookie,
|
||||
"Accept": "text/html",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d, want %d: %s", target, resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatPhoneCodeDoesNotBindMockPhone(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_code_only"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
||||
}
|
||||
var session struct {
|
||||
User struct {
|
||||
Openid string `json:"openid"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"test-phone-code"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("phone code bind status = %d, want %d: %s", resp.StatusCode, http.StatusInternalServerError, body)
|
||||
}
|
||||
if !strings.Contains(body, "GUAHAO_WECHAT_APPID") || !strings.Contains(body, "GUAHAO_WECHAT_SECRET") {
|
||||
t.Fatalf("phone code bind error = %s, want missing WeChat configuration error", body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phone":"13900002222"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body = readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("explicit phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if strings.Contains(body, "13800005678") || !strings.Contains(body, "13900002222") {
|
||||
t.Fatalf("explicit phone bind body = %s, want real provided phone only", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatPhoneBindCreatesMissingDemoUser(t *testing.T) {
|
||||
app, closeDB := newTestAdminApp(t)
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"demo_openid_demo","phone":"13900004444"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if !strings.Contains(body, `"openid":"demo_openid_demo"`) || !strings.Contains(body, "13900004444") {
|
||||
t.Fatalf("phone bind body = %s, want created demo user with bound phone", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatPhoneCodeExchangesAndBindsRealPhone(t *testing.T) {
|
||||
var tokenCalls atomic.Int64
|
||||
var phoneCalls atomic.Int64
|
||||
wechat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cgi-bin/token":
|
||||
tokenCalls.Add(1)
|
||||
if r.URL.Query().Get("grant_type") != "client_credential" {
|
||||
t.Fatalf("grant_type = %q, want client_credential", r.URL.Query().Get("grant_type"))
|
||||
}
|
||||
if r.URL.Query().Get("appid") != "test-appid" || r.URL.Query().Get("secret") != "test-secret" {
|
||||
t.Fatalf("unexpected appid/secret query: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":7200}`))
|
||||
case "/wxa/business/getuserphonenumber":
|
||||
phoneCalls.Add(1)
|
||||
if r.URL.Query().Get("access_token") != "test-token" {
|
||||
t.Fatalf("access_token = %q, want test-token", r.URL.Query().Get("access_token"))
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode phone request body: %v", err)
|
||||
}
|
||||
if body.Code != "real-phone-code" {
|
||||
t.Fatalf("phone code = %q, want real-phone-code", body.Code)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","phone_info":{"phoneNumber":"13900003333","purePhoneNumber":"13900003333","countryCode":"86"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer wechat.Close()
|
||||
|
||||
app, closeDB := newTestAdminAppWithConfig(t, config.Config{
|
||||
WeChatAppID: "test-appid",
|
||||
WeChatAppSecret: "test-secret",
|
||||
WeChatAPIBase: wechat.URL,
|
||||
})
|
||||
defer closeDB()
|
||||
|
||||
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_exchange"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp))
|
||||
}
|
||||
var session struct {
|
||||
User struct {
|
||||
Openid string `json:"openid"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"real-phone-code"}`, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
if !strings.Contains(body, "13900003333") || strings.Contains(body, "13800005678") {
|
||||
t.Fatalf("phone bind body = %s, want exchanged real phone only", body)
|
||||
}
|
||||
if tokenCalls.Load() != 1 || phoneCalls.Load() != 1 {
|
||||
t.Fatalf("tokenCalls/phoneCalls = %d/%d, want 1/1", tokenCalls.Load(), phoneCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAdminApp(t *testing.T) (*fiber.App, func()) {
|
||||
return newTestAdminAppWithConfig(t, config.Config{})
|
||||
}
|
||||
|
||||
func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber.App, func()) {
|
||||
t.Helper()
|
||||
database, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
schemaPath := filepath.Join("..", "..", "migrations", "schema.sql")
|
||||
if err := service.ApplySchema(ctx, database, schemaPath); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("apply schema: %v", err)
|
||||
}
|
||||
log := logrus.New()
|
||||
log.SetOutput(io.Discard)
|
||||
svc := service.New(database, log)
|
||||
if err := svc.Seed(ctx); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
session, err := svc.Login(ctx, "admin_test")
|
||||
if err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("login seeded user: %v", err)
|
||||
}
|
||||
if _, err := svc.BindPhone(ctx, session.User.Openid, "13900001111"); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("bind seeded phone: %v", err)
|
||||
}
|
||||
_, err = svc.CreateBooking(ctx, service.CreateBookingRequest{
|
||||
OpenID: session.User.Openid,
|
||||
ProjectID: "project_fresh_clean",
|
||||
Date: time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
|
||||
StartTime: "09:30",
|
||||
Phone: "13900001111",
|
||||
})
|
||||
if err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("create seeded booking: %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
AllowOrigins: "*",
|
||||
UploadDir: t.TempDir(),
|
||||
WeChatAppID: cfgOverride.WeChatAppID,
|
||||
WeChatAppSecret: cfgOverride.WeChatAppSecret,
|
||||
WeChatAPIBase: cfgOverride.WeChatAPIBase,
|
||||
AdminAuthMode: config.AdminAuthModeNone,
|
||||
AdminSessionSecret: "test-session-secret",
|
||||
AdminPasswordChangeRedirectURL: cfgOverride.AdminPasswordChangeRedirectURL,
|
||||
OIDCIssuer: cfgOverride.OIDCIssuer,
|
||||
OIDCClientID: cfgOverride.OIDCClientID,
|
||||
OIDCClientSecret: cfgOverride.OIDCClientSecret,
|
||||
OIDCRedirectURI: cfgOverride.OIDCRedirectURI,
|
||||
OIDCScopes: cfgOverride.OIDCScopes,
|
||||
}
|
||||
if cfgOverride.AdminAuthMode != "" {
|
||||
cfg.AdminAuthMode = cfgOverride.AdminAuthMode
|
||||
}
|
||||
if cfgOverride.AdminSessionSecret != "" {
|
||||
cfg.AdminSessionSecret = cfgOverride.AdminSessionSecret
|
||||
}
|
||||
if cfg.AdminPasswordChangeRedirectURL == "" {
|
||||
cfg.AdminPasswordChangeRedirectURL = "https://gh.yqbmb.com/admin/price-inquiries"
|
||||
}
|
||||
return New(cfg, svc, log), func() { database.Close() }
|
||||
}
|
||||
|
||||
func createTestPriceInquiry(t *testing.T, app *fiber.App, code, phone string) string {
|
||||
t.Helper()
|
||||
|
||||
resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", fmt.Sprintf(`{"code":%q}`, code), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
var session struct {
|
||||
User struct {
|
||||
Openid string `json:"openid"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("session status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", fmt.Sprintf(`{"openid":%q,"phone":%q}`, session.User.Openid, phone), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
body := readBody(t, resp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("phone bind status = %d, want %d: %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
|
||||
resp = doRequest(t, app, http.MethodPost, "/api/price-inquiries", fmt.Sprintf(`{"openid":%q,"province":"上海市","city":"上海市","district":"浦东新区","project_name":"牙齿种植"}`, session.User.Openid), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
body = readBody(t, resp)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("price inquiry status = %d, want %d: %s", resp.StatusCode, http.StatusCreated, body)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &created); err != nil {
|
||||
t.Fatalf("decode price inquiry: %v", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatalf("price inquiry missing id: %s", body)
|
||||
}
|
||||
return created.ID
|
||||
}
|
||||
|
||||
func loginAdmin(t *testing.T, app *fiber.App) string {
|
||||
t.Helper()
|
||||
return ""
|
||||
}
|
||||
|
||||
func adminSessionCookieHeader(cfg config.Config, user adminUser) string {
|
||||
ui := newAdminUI(cfg, nil)
|
||||
token := ui.signSession(user, time.Now().Add(8*time.Hour))
|
||||
return adminSessionCookie + "=" + token
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, app *fiber.App, method, target, body string, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
return testRequest(t, app, req, headers)
|
||||
}
|
||||
|
||||
func testRequest(t *testing.T, app *fiber.App, req *http.Request, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL.String(), err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/compress"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db"
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App {
|
||||
wechatPhone := newWeChatPhoneClient(cfg)
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Smile First API",
|
||||
BodyLimit: 8 * 1024 * 1024,
|
||||
ErrorHandler: errorHandler,
|
||||
})
|
||||
app.Use(recover.New())
|
||||
app.Use(compress.New())
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: splitList(cfg.AllowOrigins),
|
||||
AllowHeaders: []string{
|
||||
fiber.HeaderOrigin,
|
||||
fiber.HeaderContentType,
|
||||
fiber.HeaderAccept,
|
||||
fiber.HeaderAuthorization,
|
||||
},
|
||||
AllowMethods: []string{
|
||||
fiber.MethodGet,
|
||||
fiber.MethodPost,
|
||||
fiber.MethodPatch,
|
||||
fiber.MethodDelete,
|
||||
fiber.MethodOptions,
|
||||
},
|
||||
}))
|
||||
app.Use(logRequests(log))
|
||||
|
||||
app.Get("/uploads/*", serveUploadedFile(cfg))
|
||||
|
||||
app.Get("/healthz", func(c fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"ok": true, "time": time.Now().Format(time.RFC3339)})
|
||||
})
|
||||
|
||||
api := app.Group("/api")
|
||||
api.Get("/app/bootstrap", func(c fiber.Ctx) error {
|
||||
heroImage, err := svc.HomeHeroImage(c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
categories, err := svc.CategoriesWithProjects(c.Context(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recommended := make([]service.ProjectView, 0, 4)
|
||||
for _, category := range categories {
|
||||
for _, project := range category.Projects {
|
||||
if len(recommended) < 4 {
|
||||
recommended = append(recommended, service.ToProjectView(project))
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"brand": fiber.Map{
|
||||
"name": "Smile First",
|
||||
"title": "选好项目,约好护理时间",
|
||||
"hero": "自然之力,予你自信笑容",
|
||||
"subtitle": "温和护理 · 精致体验 · 用心陪伴",
|
||||
"hero_image": heroImage,
|
||||
},
|
||||
"hero_image": heroImage,
|
||||
"features": []string{"自然之力,温和呵护", "精致体验,贴心服务", "预约提醒,省时省心"},
|
||||
"recommended_projects": recommended,
|
||||
})
|
||||
})
|
||||
api.Get("/projects", func(c fiber.Ctx) error {
|
||||
categories, err := svc.CategoriesWithProjects(c.Context(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"categories": service.CategoryViews(categories)})
|
||||
})
|
||||
api.Post("/auth/wechat/session", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
session, err := svc.Login(c.Context(), req.Code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(session)
|
||||
})
|
||||
api.Post("/auth/wechat/phone", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
OpenID string `json:"openid"`
|
||||
Phone string `json:"phone"`
|
||||
PhoneCode string `json:"phoneCode"`
|
||||
}
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
phoneCode := strings.TrimSpace(req.PhoneCode)
|
||||
log.WithFields(logrus.Fields{
|
||||
"openid": maskIdentifierForLog(req.OpenID),
|
||||
"has_openid": strings.TrimSpace(req.OpenID) != "",
|
||||
"has_phone": phone != "",
|
||||
"phone": maskPhoneForLog(phone),
|
||||
"has_phone_code": phoneCode != "",
|
||||
"phone_code_len": len(phoneCode),
|
||||
"ip": c.IP(),
|
||||
}).Info("wechat phone bind request")
|
||||
if phone == "" {
|
||||
if phoneCode != "" {
|
||||
exchangedPhone, err := wechatPhone.PhoneNumber(c.Context(), phoneCode)
|
||||
if err != nil {
|
||||
log.WithFields(logrus.Fields{
|
||||
"openid": maskIdentifierForLog(req.OpenID),
|
||||
"phone_code_len": len(phoneCode),
|
||||
}).WithError(err).Error("wechat phone exchange failed")
|
||||
var configErr missingWeChatConfigError
|
||||
if errors.As(err, &configErr) {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return fiber.NewError(fiber.StatusBadGateway, err.Error())
|
||||
}
|
||||
phone = exchangedPhone
|
||||
log.WithFields(logrus.Fields{
|
||||
"openid": maskIdentifierForLog(req.OpenID),
|
||||
"phone_code_len": len(phoneCode),
|
||||
"phone": maskPhoneForLog(phone),
|
||||
}).Info("wechat phone exchange success")
|
||||
}
|
||||
}
|
||||
if phone == "" {
|
||||
log.WithField("openid", maskIdentifierForLog(req.OpenID)).Warn("wechat phone bind rejected: phone missing")
|
||||
return badRequest("phone is required")
|
||||
}
|
||||
user, err := svc.BindPhone(c.Context(), req.OpenID, phone)
|
||||
if err != nil {
|
||||
log.WithFields(logrus.Fields{
|
||||
"openid": maskIdentifierForLog(req.OpenID),
|
||||
"phone": maskPhoneForLog(phone),
|
||||
}).WithError(err).Error("wechat phone bind failed")
|
||||
return err
|
||||
}
|
||||
log.WithFields(logrus.Fields{
|
||||
"openid": maskIdentifierForLog(user.Openid),
|
||||
"user_id": user.ID,
|
||||
"phone": maskPhoneForLog(phone),
|
||||
}).Info("wechat phone bind success")
|
||||
return c.JSON(fiber.Map{"user": user})
|
||||
})
|
||||
api.Post("/bookings", func(c fiber.Ctx) error {
|
||||
var req service.CreateBookingRequest
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := svc.CreateBooking(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(service.BookingDetailToView(detail))
|
||||
})
|
||||
api.Post("/price-inquiries", func(c fiber.Ctx) error {
|
||||
var req service.CreatePriceInquiryRequest
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
inquiry, err := svc.CreatePriceInquiry(c.Context(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return badRequest("phone authorization is required")
|
||||
}
|
||||
if strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "unsupported") {
|
||||
return badRequest(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
log.WithFields(logrus.Fields{
|
||||
"inquiry_id": inquiry.ID,
|
||||
"openid": maskIdentifierForLog(req.OpenID),
|
||||
"phone": maskPhoneForLog(inquiry.Phone),
|
||||
"province": inquiry.Province,
|
||||
"city": inquiry.City,
|
||||
"district": inquiry.District,
|
||||
"project_name": inquiry.ProjectName,
|
||||
"status": inquiry.Status,
|
||||
"created_at": inquiry.CreatedAt,
|
||||
"updated_at": inquiry.UpdatedAt,
|
||||
}).Info("price inquiry submitted")
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"id": inquiry.ID,
|
||||
"status": inquiry.Status,
|
||||
"created_at": inquiry.CreatedAt,
|
||||
"updated_at": inquiry.UpdatedAt,
|
||||
})
|
||||
})
|
||||
api.Get("/bookings", func(c fiber.Ctx) error {
|
||||
phone := strings.TrimSpace(c.Query("phone"))
|
||||
if phone == "" {
|
||||
return badRequest("phone is required")
|
||||
}
|
||||
details, err := svc.ListBookingDetailsByPhone(c.Context(), phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"bookings": service.BookingDetailsToViews(details)})
|
||||
})
|
||||
api.Get("/bookings/:id", func(c fiber.Ctx) error {
|
||||
booking, err := svc.Q.GetBooking(c.Context(), c.Params("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := svc.BookingDetail(c.Context(), booking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(service.BookingDetailToView(detail))
|
||||
})
|
||||
api.Patch("/bookings/:id/cancel", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := svc.CancelBooking(c.Context(), c.Params("id"), req.Phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(service.BookingDetailToView(detail))
|
||||
})
|
||||
|
||||
adminUI := newAdminUI(cfg, svc)
|
||||
adminUI.registerPublic(app)
|
||||
admin := app.Group("/admin", adminUI.requireLogin)
|
||||
adminUI.registerPages(admin)
|
||||
admin.Get("/categories", func(c fiber.Ctx) error {
|
||||
categories, err := svc.CategoriesWithProjects(c.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"categories": categories})
|
||||
})
|
||||
admin.Post("/categories", func(c fiber.Ctx) error {
|
||||
var req db.CreateCategoryParams
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ID == "" {
|
||||
req.ID = "cat_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
category, err := svc.Q.CreateCategory(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(category)
|
||||
})
|
||||
admin.Get("/projects", func(c fiber.Ctx) error {
|
||||
categories, err := svc.CategoriesWithProjects(c.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"categories": categories})
|
||||
})
|
||||
admin.Post("/projects", func(c fiber.Ctx) error {
|
||||
var req db.CreateProjectParams
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ID == "" {
|
||||
req.ID = "project_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "active"
|
||||
}
|
||||
project, err := svc.Q.CreateProject(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(project)
|
||||
})
|
||||
admin.Get("/schedules", func(c fiber.Ctx) error {
|
||||
projectID := firstNonEmpty(c.Query("projectId"), c.Query("project_id"))
|
||||
days, err := svc.ScheduleDays(c.Context(), projectID, c.Query("month"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"days": days})
|
||||
})
|
||||
admin.Post("/schedules/days", func(c fiber.Ctx) error {
|
||||
var req db.CreateScheduleDayParams
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ID == "" {
|
||||
req.ID = "day_" + req.ProjectID + "_" + strings.ReplaceAll(req.Date, "-", "")
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "available"
|
||||
}
|
||||
day, err := svc.Q.CreateScheduleDay(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(day)
|
||||
})
|
||||
admin.Patch("/schedules/days/:id", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
day, err := svc.Q.UpdateScheduleDay(c.Context(), db.UpdateScheduleDayParams{
|
||||
ID: c.Params("id"), Status: req.Status, Note: req.Note,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(day)
|
||||
})
|
||||
admin.Post("/schedules/slots", func(c fiber.Ctx) error {
|
||||
var req service.CreateTimeSlotRequest
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
slot, err := svc.CreateTimeSlot(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(slot)
|
||||
})
|
||||
admin.Patch("/schedules/slots/:id", func(c fiber.Ctx) error {
|
||||
var req service.UpdateTimeSlotRequest
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
slot, err := svc.UpdateTimeSlot(c.Context(), c.Params("id"), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(slot)
|
||||
})
|
||||
admin.Get("/bookings", func(c fiber.Ctx) error {
|
||||
details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), queryInt(c, "limit", 50), queryInt(c, "offset", 0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{"bookings": details})
|
||||
})
|
||||
admin.Patch("/bookings/:id/status", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := bindBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
booking, err := svc.Q.UpdateBookingStatus(c.Context(), db.UpdateBookingStatusParams{ID: c.Params("id"), Status: req.Status})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := svc.BookingDetail(c.Context(), booking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(detail)
|
||||
})
|
||||
admin.Get("/reports/bookings", func(c fiber.Ctx) error {
|
||||
details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), 500, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Query("format") != "csv" {
|
||||
return c.JSON(fiber.Map{"bookings": details})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"booking_id", "status", "phone", "project", "date", "time", "created_at"})
|
||||
for _, detail := range details {
|
||||
_ = w.Write([]string{
|
||||
detail.Booking.ID, detail.Booking.Status, detail.Booking.Phone,
|
||||
detail.Project.Name, detail.Day.Date, detail.Slot.StartTime,
|
||||
detail.Booking.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
c.Set(fiber.HeaderContentType, "text/csv; charset=utf-8")
|
||||
c.Set(fiber.HeaderContentDisposition, `attachment; filename="bookings.csv"`)
|
||||
return c.Send(buf.Bytes())
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func serveUploadedFile(cfg config.Config) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
cleanPath := path.Clean("/" + c.Params("*"))
|
||||
if cleanPath == "/" {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
fullPath := filepath.Join(cfg.UploadDir, filepath.FromSlash(strings.TrimPrefix(cleanPath, "/")))
|
||||
base, err := filepath.Abs(cfg.UploadDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
full, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full != base && !strings.HasPrefix(full, base+string(filepath.Separator)) {
|
||||
return fiber.ErrForbidden
|
||||
}
|
||||
c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
|
||||
return c.SendFile(full)
|
||||
}
|
||||
}
|
||||
|
||||
func errorHandler(c fiber.Ctx, err error) error {
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
return c.Status(e.Code).JSON(fiber.Map{"error": e.Message})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
func badRequest(msg string) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
func bindBody(c fiber.Ctx, out any) error {
|
||||
if err := c.Bind().Body(out); err != nil {
|
||||
return badRequest(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logRequests(log *logrus.Logger) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
log.WithFields(logrus.Fields{
|
||||
"method": c.Method(),
|
||||
"path": c.Path(),
|
||||
"status": c.Response().StatusCode(),
|
||||
"latency_ms": time.Since(start).Milliseconds(),
|
||||
}).Info("request")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func maskPhoneForLog(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if len(phone) < 7 {
|
||||
return phone
|
||||
}
|
||||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||||
}
|
||||
|
||||
func maskIdentifierForLog(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 8 {
|
||||
return value
|
||||
}
|
||||
return value[:4] + "..." + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func queryInt(c fiber.Ctx, key string, fallback int64) int64 {
|
||||
value, err := strconv.ParseInt(c.Query(key), 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
{{define "admin_head"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.PageTitle}} - 管理后台</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f4;
|
||||
--panel: #ffffff;
|
||||
--line: #dfe5dd;
|
||||
--text: #1d2b27;
|
||||
--muted: #687873;
|
||||
--accent: #0f6f63;
|
||||
--accent-dark: #0a5148;
|
||||
--warn: #a63f2d;
|
||||
--ok: #2f7d51;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 64px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ghost-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost-button:hover { color: var(--accent-dark); }
|
||||
.logout-form {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.session-name {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin: 28px 28px 56px;
|
||||
}
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
h1, h2, h3 { margin: 0; line-height: 1.2; }
|
||||
h1 { font-size: 26px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; }
|
||||
.muted { color: var(--muted); }
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: 0 10px 26px rgba(29, 43, 39, 0.05);
|
||||
}
|
||||
.notice,
|
||||
.error {
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.error-text {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
color: var(--warn);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notice {
|
||||
color: var(--ok);
|
||||
border-color: #bcdcc8;
|
||||
background: #eff8f2;
|
||||
}
|
||||
.error {
|
||||
color: var(--warn);
|
||||
border-color: #edc1b7;
|
||||
background: #fff3f0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #ccd6d1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 9px 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
textarea { min-height: 70px; resize: vertical; }
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(15, 111, 99, 0.18);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.span-2 { grid-column: span 2; }
|
||||
.span-3 { grid-column: span 3; }
|
||||
.span-4 { grid-column: span 4; }
|
||||
.span-5 { grid-column: span 5; }
|
||||
.span-6 { grid-column: span 6; }
|
||||
.span-7 { grid-column: span 7; }
|
||||
.span-8 { grid-column: span 8; }
|
||||
.span-9 { grid-column: span 9; }
|
||||
.span-12 { grid-column: span 12; }
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button.secondary {
|
||||
background: #fff;
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.button.small {
|
||||
min-height: 28px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.button.danger {
|
||||
border-color: #c86b5b;
|
||||
background: #fff;
|
||||
color: var(--warn);
|
||||
}
|
||||
.button.disabled {
|
||||
border-color: #ccd6d1;
|
||||
color: #9aa6a1;
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.filter-form {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-form input,
|
||||
.filter-form select { min-width: 220px; }
|
||||
.result-summary { margin: -2px 0 12px; }
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #cdd9d3;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: #f7faf8;
|
||||
color: var(--accent-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status-pill.status-pending {
|
||||
border-color: #f2c94c;
|
||||
background: #fff8db;
|
||||
color: #7a4f00;
|
||||
}
|
||||
.status-pill.status-assigned {
|
||||
border-color: #87b7ff;
|
||||
background: #edf5ff;
|
||||
color: #154b8b;
|
||||
}
|
||||
.status-pill.status-completed {
|
||||
border-color: #7fc99a;
|
||||
background: #edf9f0;
|
||||
color: #17613a;
|
||||
}
|
||||
.status-pill.status-unknown {
|
||||
border-color: #c9c9c9;
|
||||
background: #f5f5f5;
|
||||
color: #555;
|
||||
}
|
||||
.remark-cell {
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
white-space: normal;
|
||||
}
|
||||
.settings { display: inline-block; }
|
||||
.settings summary { list-style: none; }
|
||||
.settings summary::-webkit-details-marker { display: none; }
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 240px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
background: #eef2ed;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.category-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.category-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.project-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.project-form {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #fbfcfa;
|
||||
}
|
||||
.image-preview {
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #eef2ed;
|
||||
}
|
||||
.hero-preview {
|
||||
width: 140px;
|
||||
height: 84px;
|
||||
flex: none;
|
||||
}
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.login-panel {
|
||||
width: min(420px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 20px 60px rgba(29, 43, 39, 0.12);
|
||||
}
|
||||
.login-panel h1 { margin-bottom: 6px; }
|
||||
.login-panel form { margin-top: 22px; }
|
||||
@media (max-width: 860px) {
|
||||
.brand { padding: 0; white-space: nowrap; }
|
||||
.topbar {
|
||||
min-height: 56px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.shell { margin: 20px 12px 40px; }
|
||||
.page-title { align-items: flex-start; flex-direction: column; }
|
||||
.toolbar,
|
||||
.filter-form { align-items: stretch; flex-direction: column; }
|
||||
.pagination { align-items: stretch; flex-direction: column; }
|
||||
.pagination-actions { justify-content: flex-start; }
|
||||
.filter-form input,
|
||||
.filter-form select { min-width: 0; }
|
||||
.project-list { grid-template-columns: 1fr; }
|
||||
.span-2,
|
||||
.span-3,
|
||||
.span-4,
|
||||
.span-5,
|
||||
.span-6,
|
||||
.span-7,
|
||||
.span-8,
|
||||
.span-9 { grid-column: span 12; }
|
||||
th,
|
||||
td { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_nav"}}
|
||||
<header class="topbar">
|
||||
<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>
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "login.html"}}
|
||||
{{template "admin_head" .}}
|
||||
<body>
|
||||
<main class="login-shell">
|
||||
<section class="login-panel">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,132 @@
|
||||
{{define "price_inquiries.html"}}
|
||||
{{template "admin_head" .}}
|
||||
<body>
|
||||
{{template "admin_nav" .}}
|
||||
<main class="shell">
|
||||
<div class="toolbar">
|
||||
<form class="filter-form" method="get" action="/admin/price-inquiries">
|
||||
<div class="field">
|
||||
<label for="area">地区筛选</label>
|
||||
<input id="area" name="area" value="{{.AreaFilter}}" placeholder="省、市或区县">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">手机号</label>
|
||||
<input id="phone" name="phone" value="{{.PhoneFilter}}" placeholder="手机号">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">状态</label>
|
||||
<select id="status" name="status">
|
||||
<option value="" {{if eq .StatusFilter ""}}selected{{end}}>全部状态</option>
|
||||
<option value="pending" {{if eq .StatusFilter "pending"}}selected{{end}}>待派单</option>
|
||||
<option value="assigned" {{if eq .StatusFilter "assigned"}}selected{{end}}>已经派单</option>
|
||||
<option value="completed" {{if eq .StatusFilter "completed"}}selected{{end}}>已跟进</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="time_from">筛选时间起</label>
|
||||
<input id="time_from" name="time_from" value="{{.TimeFromFilter}}" type="date">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="time_to">筛选时间止</label>
|
||||
<input id="time_to" name="time_to" value="{{.TimeToFilter}}" type="date">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="page_size">每页条数</label>
|
||||
<select id="page_size" name="page_size">
|
||||
{{range .PriceInquiryPageSizeOptions}}
|
||||
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Value}} 条/页</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button class="button" type="submit">筛选</button>
|
||||
{{if .HasPriceInquiryFilter}}<a class="button secondary" href="/admin/price-inquiries">全部</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="muted result-summary">共 {{.PriceInquiryTotal}} 条咨询,第 {{.PriceInquiryPage}} / {{.PriceInquiryTotalPages}} 页</div>
|
||||
|
||||
{{if .Message}}<div class="notice">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>手机号</th>
|
||||
<th>咨询项目</th>
|
||||
<th>所在地区</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>最近提交时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>备注</th>
|
||||
<th>设置</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .PriceInquiryRecords}}
|
||||
<tr>
|
||||
<td><strong>{{.Phone}}</strong></td>
|
||||
<td>{{.ProjectName}}</td>
|
||||
<td>{{.Region}}</td>
|
||||
<td><span class="status-pill {{.StatusClass}}">{{.StatusText}}</span></td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.LastSubmittedAt}}</td>
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
<td class="remark-cell">{{if .Remark}}{{.Remark}}{{else}}-{{end}}</td>
|
||||
<td>
|
||||
<details class="settings">
|
||||
<summary class="button secondary small">设置</summary>
|
||||
<form class="settings-form" method="post" action="/admin/price-inquiries/settings">
|
||||
<input type="hidden" name="id" value="{{.InquiryID}}">
|
||||
<input type="hidden" name="area" value="{{$.AreaFilter}}">
|
||||
<input type="hidden" name="phone" value="{{$.PhoneFilter}}">
|
||||
<input type="hidden" name="status_filter" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="time_from" value="{{$.TimeFromFilter}}">
|
||||
<input type="hidden" name="time_to" value="{{$.TimeToFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.PriceInquiryPage}}">
|
||||
<input type="hidden" name="page_size" value="{{$.PriceInquiryPageSize}}">
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<select name="status">
|
||||
<option value="pending" {{if eq .Status "pending"}}selected{{end}}>待派单</option>
|
||||
<option value="assigned" {{if eq .Status "assigned"}}selected{{end}}>已经派单</option>
|
||||
<option value="completed" {{if eq .Status "completed"}}selected{{end}}>已跟进</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<textarea name="remark" placeholder="填写备注信息">{{.Remark}}</textarea>
|
||||
</div>
|
||||
<button class="button" type="submit">保存</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="9" class="muted">暂无价格咨询数据</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<div class="muted">每页 {{.PriceInquiryPageSize}} 条</div>
|
||||
<div class="pagination-actions">
|
||||
{{if .PriceInquiryHasPreviousPage}}
|
||||
<a class="button secondary small" href="{{.PriceInquiryPreviousURL}}">上一页</a>
|
||||
{{else}}
|
||||
<span class="button secondary small disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if .PriceInquiryHasNextPage}}
|
||||
<a class="button secondary small" href="{{.PriceInquiryNextURL}}">下一页</a>
|
||||
{{else}}
|
||||
<span class="button secondary small disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,200 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
||||
)
|
||||
|
||||
const defaultWeChatAPIBase = "https://api.weixin.qq.com"
|
||||
|
||||
type wechatPhoneClient struct {
|
||||
appID string
|
||||
appSecret string
|
||||
apiBase string
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cachedAccessToken string
|
||||
accessTokenExp time.Time
|
||||
}
|
||||
|
||||
type missingWeChatConfigError struct {
|
||||
missing []string
|
||||
}
|
||||
|
||||
func (e missingWeChatConfigError) Error() string {
|
||||
return "missing WeChat app configuration: set " + strings.Join(e.missing, ", ")
|
||||
}
|
||||
|
||||
type wechatAccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type wechatPhoneResponse struct {
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
PhoneInfo struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"phone_info"`
|
||||
}
|
||||
|
||||
func newWeChatPhoneClient(cfg config.Config) *wechatPhoneClient {
|
||||
apiBase := strings.TrimRight(strings.TrimSpace(cfg.WeChatAPIBase), "/")
|
||||
if apiBase == "" {
|
||||
apiBase = defaultWeChatAPIBase
|
||||
}
|
||||
return &wechatPhoneClient{
|
||||
appID: strings.TrimSpace(cfg.WeChatAppID),
|
||||
appSecret: strings.TrimSpace(cfg.WeChatAppSecret),
|
||||
apiBase: apiBase,
|
||||
client: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wechatPhoneClient) PhoneNumber(ctx context.Context, phoneCode string) (string, error) {
|
||||
phoneCode = strings.TrimSpace(phoneCode)
|
||||
if phoneCode == "" {
|
||||
return "", errors.New("phoneCode is required")
|
||||
}
|
||||
token, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{"code": phoneCode})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
endpoint := c.apiURL("/wxa/business/getuserphonenumber")
|
||||
query := endpoint.Query()
|
||||
query.Set("access_token", token)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request WeChat phone API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("WeChat phone API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data wechatPhoneResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("decode WeChat phone response: %w", err)
|
||||
}
|
||||
if data.ErrCode != 0 {
|
||||
return "", fmt.Errorf("WeChat phone API error %d: %s", data.ErrCode, data.ErrMsg)
|
||||
}
|
||||
phone := strings.TrimSpace(data.PhoneInfo.PhoneNumber)
|
||||
if phone == "" {
|
||||
phone = strings.TrimSpace(data.PhoneInfo.PurePhoneNumber)
|
||||
}
|
||||
if phone == "" {
|
||||
return "", errors.New("WeChat phone API returned empty phone number")
|
||||
}
|
||||
return phone, nil
|
||||
}
|
||||
|
||||
func (c *wechatPhoneClient) accessToken(ctx context.Context) (string, error) {
|
||||
if err := c.validateConfig(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cachedAccessToken != "" && time.Now().Before(c.accessTokenExp) {
|
||||
return c.cachedAccessToken, nil
|
||||
}
|
||||
|
||||
endpoint := c.apiURL("/cgi-bin/token")
|
||||
query := endpoint.Query()
|
||||
query.Set("grant_type", "client_credential")
|
||||
query.Set("appid", c.appID)
|
||||
query.Set("secret", c.appSecret)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request WeChat access_token API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("WeChat access_token API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data wechatAccessTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("decode WeChat access_token response: %w", err)
|
||||
}
|
||||
if data.ErrCode != 0 {
|
||||
return "", fmt.Errorf("WeChat access_token API error %d: %s", data.ErrCode, data.ErrMsg)
|
||||
}
|
||||
if strings.TrimSpace(data.AccessToken) == "" {
|
||||
return "", errors.New("WeChat access_token API returned empty access_token")
|
||||
}
|
||||
c.cachedAccessToken = strings.TrimSpace(data.AccessToken)
|
||||
c.accessTokenExp = tokenExpiry(data.ExpiresIn)
|
||||
return c.cachedAccessToken, nil
|
||||
}
|
||||
|
||||
func (c *wechatPhoneClient) validateConfig() error {
|
||||
missing := make([]string, 0, 2)
|
||||
if c.appID == "" {
|
||||
missing = append(missing, "GUAHAO_WECHAT_APPID")
|
||||
}
|
||||
if c.appSecret == "" {
|
||||
missing = append(missing, "GUAHAO_WECHAT_SECRET")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return missingWeChatConfigError{missing: missing}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *wechatPhoneClient) apiURL(path string) url.URL {
|
||||
base, err := url.Parse(c.apiBase)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
base, _ = url.Parse(defaultWeChatAPIBase)
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + path
|
||||
base.RawQuery = ""
|
||||
return *base
|
||||
}
|
||||
|
||||
func tokenExpiry(expiresIn int64) time.Time {
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
refreshSkew := int64(300)
|
||||
if expiresIn <= refreshSkew*2 {
|
||||
refreshSkew = expiresIn / 2
|
||||
}
|
||||
return time.Now().Add(time.Duration(expiresIn-refreshSkew) * time.Second)
|
||||
}
|
||||
Reference in New Issue
Block a user