Rename config vars. Add widget holder template and endpoint to serve it. Add dockerfile

This commit is contained in:
2026-03-31 22:18:41 +03:00
parent 33da1338bb
commit 6d67e969e0
16 changed files with 207 additions and 28 deletions

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.26-alpine3.23 AS build
WORKDIR /payoutsbuild
RUN apk add --update --no-cache ca-certificates git
ENV GOBIN=/payoutsbuild/bin
ADD . /payoutsbuild
RUN cd /payoutsbuild && \
go mod download && \
go build ./cmd/...
FROM alpine:3.23
WORKDIR /app
COPY --from=build /payoutsbuild/payouts /app/
COPY --from=build /payoutsbuild/config/payouts.properties /app/
EXPOSE 8080
ENTRYPOINT [ "/app/payouts" ]

View File

@@ -150,8 +150,8 @@
| YooKassa.Test | Enable test mode | false |
| YooKassa.ApiBaseKey | YooKassa base API key | (empty) |
| YooKassa.ApiBaseSecret | YooKassa base API secret | (empty) |
| YooKassa.ApiPaymentKey | YooKassa payment API key | (empty) |
| YooKassa.ApiPaymentSecret | YooKassa payment API secret | (empty) |
| YooKassa.ApiPayoutKey | YooKassa payout API key | (empty) |
| YooKassa.ApiPayoutSecret | YooKassa payout API secret | (empty) |
| YooKassa.BaseUrl | YooKassa API base URL | https://api.yookassa.ru/v3 |
| YooKassa.Timeout | Request timeout | 2s |
| YooKassa.CheckAllowedCallbackAddress | Check callback IP addresses | true |

View File

@@ -55,8 +55,10 @@ YooKassa.AllowedCallbackSubnets = 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,7
# Base API key/secret
YooKassa.ApiBaseKey =
YooKassa.ApiBaseSecret =
# Payments API key/secret
YooKassa.ApiPaymentKey =
YooKassa.ApiPaymentSecret =
# Payouts API key/secret
YooKassa.ApiPayoutKey =
YooKassa.ApiPayoutSecret =
# Timeout to process yookassa callback
YooKassa.CallbackProcessTimeout = 1s
# Widget version
YooKassa.WidgetVersion = 3.1.0

View File

@@ -156,8 +156,8 @@ variables. Variable names are the uppercased property key with `.` replaced by `
| `DATABASE_CONNECTION` | `Database.Connection` |
| `YOOKASSA_APIBASEKEY` | `YooKassa.ApiBaseKey` |
| `YOOKASSA_APIBASESECRET` | `YooKassa.ApiBaseSecret` |
| `YOOKASSA_APIPAYMENTKEY` | `YooKassa.ApiPaymentKey` |
| `YOOKASSA_APIPAYMENTSECRET` | `YooKassa.ApiPaymentSecret` |
| `YOOKASSA_APIPAYOUTKEY` | `YooKassa.ApiPayoutKey` |
| `YOOKASSA_APIPAYOUTSECRET` | `YooKassa.ApiPayoutSecret` |
Provide secrets at install/upgrade time:
@@ -166,8 +166,8 @@ helm install payouts ./helm \
--set secrets.DATABASE_CONNECTION="host=127.0.0.1 user=app password=s3cr3t dbname=payouts port=5432 sslmode=disable" \
--set secrets.YOOKASSA_APIBASEKEY="<key>" \
--set secrets.YOOKASSA_APIBASESECRET="<secret>" \
--set secrets.YOOKASSA_APIPAYMENTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYMENTSECRET="<secret>"
--set secrets.YOOKASSA_APIPAYOUTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYOUTSECRET="<secret>"
```
Or keep them in a separate values file that is **not committed to version control**:
@@ -180,11 +180,11 @@ Example `secrets.values.yaml`:
```yaml
secrets:
DATABASE_CONNECTION: "host=127.0.0.1 user=app password=s3cr3t dbname=payouts port=5432 sslmode=disable"
DATABASE_CONNECTION: "host=127.0.0.1 user=payouts password=password dbname=payouts port=5432 sslmode=disable"
YOOKASSA_APIBASEKEY: "<key>"
YOOKASSA_APIBASESECRET: "<secret>"
YOOKASSA_APIPAYMENTKEY: "<key>"
YOOKASSA_APIPAYMENTSECRET: "<secret>"
YOOKASSA_APIPAYOUTKEY: "<key>"
YOOKASSA_APIPAYOUTSECRET: "<secret>"
```
### Ingress example

View File

@@ -31,15 +31,15 @@
3. Secret environment variables are injected from Secret {{ include "payouts.fullname" . }}:
{{- end }}
DATABASE_CONNECTION, YOOKASSA_APIBASEKEY, YOOKASSA_APIBASESECRET,
YOOKASSA_APIPAYMENTKEY, YOOKASSA_APIPAYMENTSECRET
YOOKASSA_APIPAYOUTKEY, YOOKASSA_APIPAYOUTSECRET
Before deploying to production, populate these values:
helm upgrade {{ .Release.Name }} ./helm \
--set secrets.DATABASE_CONNECTION="host=... dbname=..." \
--set secrets.YOOKASSA_APIBASEKEY="<key>" \
--set secrets.YOOKASSA_APIBASESECRET="<secret>" \
--set secrets.YOOKASSA_APIPAYMENTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYMENTSECRET="<secret>"
--set secrets.YOOKASSA_APIPAYOUTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYOUTSECRET="<secret>"
Or use a separate values file that is not committed to version control:
helm upgrade {{ .Release.Name }} ./helm -f secrets.values.yaml

View File

@@ -101,5 +101,5 @@ secrets:
DATABASE_CONNECTION: ""
YOOKASSA_APIBASEKEY: ""
YOOKASSA_APIBASESECRET: ""
YOOKASSA_APIPAYMENTKEY: ""
YOOKASSA_APIPAYMENTSECRET: ""
YOOKASSA_APIPAYOUTKEY: ""
YOOKASSA_APIPAYOUTSECRET: ""

View File

@@ -15,6 +15,7 @@ import (
"payouts/internal/api/payout"
"payouts/internal/api/user"
"payouts/internal/api/version"
"payouts/internal/api/widget"
appConfig "payouts/internal/config"
"payouts/internal/service/monitoring"
)
@@ -25,6 +26,7 @@ var Module = fx.Options(
health.Module,
payout.Module,
version.Module,
widget.Module,
monitoring.Module,
fx.Invoke(RegisterRoutes),
@@ -41,8 +43,9 @@ type Params struct {
PayoutHandler payout.Handler
UserHandler user.Handler
Version version.Handler
HealthHandler health.Handler
Version version.Handler
Widget widget.Handler
Metrics monitoring.Metrics
}
@@ -77,6 +80,9 @@ func RegisterRoutes(p Params, lc fx.Lifecycle) {
payoutRouter.HandleFunc(payout.CreateRoute, p.PayoutHandler.PayoutCreate).Methods(http.MethodPost)
payoutRouter.HandleFunc(payout.CallbackRoute, p.PayoutHandler.PayoutCallback).Methods(http.MethodPost)
// Widget endpoint
router.HandleFunc(widget.WidgetPage, p.Widget.WidgetHandler).Methods(http.MethodGet)
// collect api metrics
apiRouter.Use(p.Metrics.GetMiddleware())

View File

@@ -122,7 +122,7 @@ func (p *payoutHandler) GetSbpBanks(w http.ResponseWriter, r *http.Request) {
}
}
// PaymentCreate implements [Handler].
// PayoutCreate implements [Handler].
func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -203,7 +203,7 @@ func (p *payoutHandler) delayedPayoutUpdate(ctx context.Context, payoutData *yoo
}
}
// PaymentCallback implements [Handler].
// PayoutCallback implements [Handler].
func (p *payoutHandler) PayoutCallback(w http.ResponseWriter, r *http.Request) {
// todo: check also the X-real-ip and/or X-Forwarded-For
if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) {

View File

@@ -0,0 +1,9 @@
package widget
import (
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewWidgetHandler),
)

View File

@@ -0,0 +1,55 @@
package widget
import (
"html/template"
"net/http"
"go.uber.org/fx"
"payouts/internal/service/yookassa"
yookassaConf "payouts/internal/service/yookassa/config"
"payouts/internal/templates"
)
const WidgetPage = "/payout/widget"
type Handler interface {
WidgetHandler(http.ResponseWriter, *http.Request)
}
type widgetHandler struct {
template *template.Template
config yookassaConf.YooKassa
}
// Params represents the module input params
type Params struct {
fx.In
YookassaService yookassa.Service
}
func NewWidgetHandler(p Params) (Handler, error) {
return &widgetHandler{
template: templates.Templates,
config: p.YookassaService.GetConfig(),
}, nil
}
// WidgetHandler renders the payouts widget page
func (h *widgetHandler) WidgetHandler(w http.ResponseWriter, r *http.Request) {
data := struct {
ApiPayoutKey string
WidgetVersion string
}{
ApiPayoutKey: h.config.ApiPayoutKey,
WidgetVersion: h.config.WidgetVersion,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := h.template.ExecuteTemplate(w, "payouts-widget.html", data)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}

View File

@@ -10,6 +10,8 @@ type PayoutType int64
const (
TypeSBP PayoutType = iota
TypeYooMoney
TypeCard
TypeCardWidget
)
func (r PayoutType) String() string {
@@ -18,6 +20,10 @@ func (r PayoutType) String() string {
return "spb"
case TypeYooMoney:
return "yoo_money"
case TypeCard:
return "bank_card"
case TypeCardWidget:
return "widget"
}
return "unknown"
}
@@ -33,8 +39,12 @@ func (r *PayoutType) UnmarshalText(text []byte) (err error) {
*r = TypeSBP
case "yoo_money":
*r = TypeYooMoney
case "bank_card":
*r = TypeCard
case "widget":
*r = TypeCardWidget
default:
err = fmt.Errorf("invalid payment type: %s", s)
err = fmt.Errorf("invalid payout type: %s", s)
}
return err
}
@@ -83,7 +93,7 @@ func (r *PayoutStatus) UnmarshalText(text []byte) (err error) {
case "failed":
*r = StatusFailed
default:
err = fmt.Errorf("invalid payment type: %s", s)
err = fmt.Errorf("invalid payout type: %s", s)
}
return err
}
@@ -96,8 +106,10 @@ type SBPBank struct {
type PayoutReq struct {
PayoutType PayoutType `json:"payout_type"`
PayoutToken string `json:"payout_token"`
AccountNumber string `json:"account_number"`
BankID string `json:"bank_id"`
CardNumber string `json:"card_number"`
Amount float32 `json:"amount"`
}

View File

@@ -13,8 +13,9 @@ type YooKassa struct {
ApiBaseKey string
ApiBaseSecret string
ApiPaymentKey string
ApiPaymentSecret string
ApiPayoutKey string
ApiPayoutSecret string
WidgetVersion string
CallbackProcessTimeout time.Duration
}

View File

@@ -39,7 +39,8 @@ type Metadata map[string]any
type PayoutRequest struct {
Amount Amount `json:"amount"`
PayoutDestinationData PayoutDestination `json:"payout_destination_data"`
PayoutToken string `json:"payout_token,omitempty"`
PayoutDestinationData PayoutDestination `json:"payout_destination_data,omitzero"`
Description string `json:"description"`
Metadata Metadata `json:"metadata"`
Test bool `json:"test"`

View File

@@ -26,7 +26,7 @@ type yookassaService struct {
func NewYookassaService(conf config.YooKassa) (Service, error) {
client := resty.New()
client.SetBaseURL(conf.BaseUrl)
client.SetBasicAuth(conf.ApiPaymentKey, conf.ApiPaymentSecret)
client.SetBasicAuth(conf.ApiPayoutKey, conf.ApiPayoutSecret)
client.SetTimeout(conf.Timeout)
if conf.Retry.Enabled {
@@ -110,10 +110,23 @@ func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.Us
switch req.PayoutType {
case models.TypeSBP:
yReq.PayoutDestinationData.Phone = userSession.Phone
yReq.PayoutDestinationData.BankID = req.BankID
yReq.PayoutDestinationData = PayoutDestination{
Phone: userSession.Phone,
BankID: req.BankID,
}
case models.TypeYooMoney:
yReq.PayoutDestinationData.AccountNumber = req.AccountNumber
case models.TypeCard:
yReq.PayoutDestinationData.Card = Card{
Number: req.CardNumber,
}
case models.TypeCardWidget:
yReq.PayoutToken = req.PayoutToken
yReq.PayoutDestinationData = PayoutDestination{}
default:
return nil, errors.New("unsupported payout type")
}

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payouts Page</title>
<script src="https://yookassa.ru/payouts-data/{{ .WidgetVersion }}/widget.js"></script>
<style>
</style>
</head>
<body>
<div id="payout-form"></div>
<script>
// Инициализация виджета. Все параметры обязательные.
const payoutsData = new window.PayoutsData({
type: 'payout',
account_id: '{{ .ApiPayoutKey }}', // Идентификатор шлюза (agentId в личном кабинете)
success_callback: function(data) {
// https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters
if (window.AndroidCallback) {
window.AndroidCallback.onWidgetData(JSON.stringify(data));
} else if (window.webkit && window.webkit.messageHandlers.iosCallback) {
window.webkit.messageHandlers.iosCallback.onWidgetData(JSON.stringify(data));
}
},
error_callback: function(error) {
// https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters-error
if (window.AndroidCallback) {
window.AndroidCallback.onWidgetError(JSON.stringify(error));
} else if (window.webkit && window.webkit.messageHandlers.iosCallback) {
window.webkit.messageHandlers.iosCallback.onWidgetError(JSON.stringify(error));
}
}
});
//Отображение формы в контейнере
payoutsData.render('payout-form')
//Метод возвращает Promise, исполнение которого говорит о полной загрузке формы сбора данных (можно не использовать).
.then(() => {
//Код, который нужно выполнить после отображения формы.
});
</script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
package templates
import (
"embed"
"html/template"
)
//go:embed *.html
var FS embed.FS
var Templates = template.Must(template.ParseFS(FS, "*.html"))