Compare commits
No commits in common. "main" and "1.3.0" have entirely different histories.
1879 changed files with 328345 additions and 1262784 deletions
96
.github/instructions/ai.instructions.md
vendored
96
.github/instructions/ai.instructions.md
vendored
|
|
@ -19,11 +19,11 @@ Companion documents (this file wins on conflict):
|
|||
| Document | Scope |
|
||||
| --- | --- |
|
||||
| `lowcode.instructions.md` | **Authoring reference**: exact schemas, enum values and worked examples for producing wizard screens, editor scripts, custom components, SQL objects and endpoints. Read it before writing any artifact. |
|
||||
| `lowcode-reference.instructions.md` | **Capability inventory**: field-by-field contract of every ListForm/ListFormField JSON column, chart and pivot options, SQL command hooks, Visual Designer toolbox, the scheduled-mail module and (§12) every ready-made screen under the Saas, Administration and HR (`Hr`) menus. Sweep its §0 checklist before producing a screen or component; a request whose answer already exists as a platform screen (§12.6) is configured there, not rebuilt. |
|
||||
| `lowcode-reference.instructions.md` | **Capability inventory**: field-by-field contract of every ListForm/ListFormField JSON column, chart and pivot options, SQL command hooks, Visual Designer toolbox, the scheduled-mail module and (§12) every ready-made screen under the Saas, Administration and HR (`App.Hr`) menus. Sweep its §0 checklist before producing a screen or component; a request whose answer already exists as a platform screen (§12.6) is configured there, not rebuilt. |
|
||||
| `dotnet.instructions.md` | Binding code standard for everything under `api/` |
|
||||
| `list.instructions.md` | Step-by-step procedure for adding a module / list through seeders |
|
||||
| `../../README.md` | Technical map: what exists, where it lives, how it is wired |
|
||||
| `../../configs/seeds/docs/modules/{Menu}.md` | **How a generated module works**: discovery answers, approved recipe, screens, formulas, status rules, permissions, FAQ. Read it first when asked how a module/component behaves; write or update it with every module/component change (`lowcode.instructions.md` §0.13). Index: `README.md` §10.5 |
|
||||
| `../../configs/seeds/host/docs/modules/{Module}.md` | **How a generated module works**: discovery answers, approved recipe, screens, formulas, status rules, permissions, FAQ. Read it first when asked how a module/component behaves; write or update it with every module/component change (`lowcode.instructions.md` §0.13). Index: `README.md` §10.5 |
|
||||
| `../../CLAUDE.md` | Operating rules and standing defaults for AI agents in this repo |
|
||||
|
||||
---
|
||||
|
|
@ -53,28 +53,26 @@ System nature:
|
|||
3. Every proposal must include tenant and permission design.
|
||||
4. Never bypass platform authorization patterns.
|
||||
5. Never hardcode secrets, tenant ids, or connection strings.
|
||||
6. **No new Core feature without the command.** Core = all source code under `api/` and `ui/`;
|
||||
the frame = artifacts generated under `configs/seeds/` (wizard, SQL objects, `crud/`, custom
|
||||
components, Custom Endpoints, Dynamic Services, workers, notifications, language/permission
|
||||
rows, SEO, module docs). A new Core feature — however small or backward compatible: a DTO
|
||||
field, a prop/event on an existing UI component, a script recipe, an editor option key, a
|
||||
default-value token, a new React component/page, engine/seeder/MCP changes, new entity/
|
||||
migration, platform table schema, NuGet/npm packages, `Sas.Menus.Routes.json` rows (except the
|
||||
`Seo*` columns of an existing row — lowcode §0.9), external integrations — is written **only
|
||||
when the user gives the command "core sisteme ekle"** (or an equivalent that names the Core
|
||||
change). Before concluding Core is needed, scan the full ListForm capability inventory
|
||||
(`lowcode-reference.instructions.md` §0–§9, lowcode §4–§9). Without the command: finish what
|
||||
fits in the frame and end with a single "Core gerektiren kısım" note (missing capability, why
|
||||
the inventory falls short, smallest change, files, rollback) — do not offer Core as an
|
||||
option to pick. Bug fixes that restore documented behaviour without new settings are not new
|
||||
features. The command covers only the named change and does not carry over. Full lists:
|
||||
6. **Stay inside the framework; ask before stepping out.** The platform may extend itself through
|
||||
its own mechanisms (seed artifacts; adding a missing event, prop, or small backward-compatible
|
||||
capability to an existing UI component — when Developer Kit tooling builds ~70% of the
|
||||
requested component, the rest is completed with such small additions, never with a new
|
||||
component; backward-compatible additions to script recipes / editor options / tokens).
|
||||
Anything beyond that — a new React component or page, a new toolbox family, core engine changes (ListForm
|
||||
runtime, seeders, code generator, auth, DataSourceManager, dispatcher/compiler), new ABP
|
||||
entity/module/migration, platform table schema changes, new NuGet/npm dependencies, the
|
||||
`App.Menus.Routes.json` seed data (except the `Seo*` columns of an existing row, which are part
|
||||
of ordinary production — lowcode §0.9), new external integrations — is **out of frame**: finish everything that
|
||||
fits inside the frame first, then ask the user with a single question (why configuration is
|
||||
insufficient, the smallest change that works, files touched, rollback) and proceed **only with
|
||||
explicit approval**. Approval is per request and does not carry over. Full lists:
|
||||
`lowcode.instructions.md` §0.8.
|
||||
|
||||
Exception policy:
|
||||
|
||||
- Code-level React or backend development happens only on the user's explicit command ("core sisteme ekle" or a request naming the code change).
|
||||
- When Core would be needed, explain in a sentence or two why the configuration inventory is not enough.
|
||||
- Never say "cannot be done": the answer is an in-frame build plus, if needed, a "Core gerektiren kısım" note.
|
||||
- Code-level React or backend development can be considered only if user explicitly requests code implementation and configuration path is insufficient.
|
||||
- If exception is required, AI must explain why configuration-based options are not enough.
|
||||
- Never say "cannot be done": the answer is either an in-frame build or an approval question.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -295,8 +293,8 @@ the Wizard's `Custom` path.
|
|||
per-command Form permissions (`selectPermission`, `insertPermission`, `updatePermission`,
|
||||
`deletePermission`). In `Otomatik` mode the base is the read permission the Wizard created for
|
||||
the component and the commands get `''` / `.Create` / `.Update` / `.Delete` suffixes.
|
||||
- List screen permissions: `Sas.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`.
|
||||
- Every save/delete is mirrored into `configs/seeds/{scope}/data/Sas.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are
|
||||
- List screen permissions: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`.
|
||||
- Every save/delete is mirrored into `configs/seeds/{scope}/data/App.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are
|
||||
referenced through `crud/{Entity}.json`. A component that is not seed-backed is not done.
|
||||
|
||||
### 10.2 SQL View Designer
|
||||
|
|
@ -310,13 +308,9 @@ designer. Aggregations available in the Group By column: `GroupBy`, `Where`, `SU
|
|||
`COUNT_DISTINCT`, `AVG`, `MIN`, `MAX`.
|
||||
|
||||
Persistence: only the **table designer's** deploy writes a seed file automatically
|
||||
(`sql/object/{TableName}.sql`, full CREATE script, via `save-table-script`; its PostgreSQL counterpart
|
||||
`postgres/object/{TableName}.sql` is generated from it by `PostgreSqlTableScript`). View
|
||||
(`{sql|postgres}/object/{TableName}.sql`, full CREATE script, via `save-table-script`). View
|
||||
Designer deploys and any DDL run from the SQL editor (views, procedures, functions) reach the
|
||||
database only — their seed files under `{sql|postgres}/object/` must be written explicitly.
|
||||
Every seed script is written for **both** providers: a `sql/…/X.sql` change always comes with its
|
||||
`postgres/…/X.sql` counterpart (schema `public`, same name and parameter order) — the platform runs on
|
||||
SQL Server and PostgreSQL alike (`lowcode.instructions.md` §8.3.1, CLAUDE.md standing default 16).
|
||||
Full mechanics and the SP/function production patterns: `lowcode.instructions.md` §8.1–§8.2.1
|
||||
and §9.5.
|
||||
|
||||
|
|
@ -367,7 +361,7 @@ AI workflow integration:
|
|||
|
||||
MCP (`/mcp`, `Sozsoft.Mcp` module) — expose ListForm data/queries to an external AI agent
|
||||
(Claude, ChatGPT, or an OAuth-registered MCP client) without new code: configure a server row
|
||||
on the **MCP Servers** screen (`Sas.DeveloperKit.McpServers`, §12.2 of
|
||||
on the **MCP Servers** screen (`App.DeveloperKit.McpServers`, §12.2 of
|
||||
`lowcode-reference.instructions.md`) with its allowed tools and tenant scope; queries always run
|
||||
on the calling tenant's own database. Tenant users read and write data over MCP but cannot develop
|
||||
(`McpToolNames.TenantTools`: schema tools, `query_list`, `run_select`, `insert_row`/`update_row`/`delete_row`
|
||||
|
|
@ -463,8 +457,8 @@ When configuration cannot satisfy requirement:
|
|||
1. Try SQL-based design
|
||||
2. Try Custom Endpoint
|
||||
3. Try Dynamic Service
|
||||
4. Describe the minimal code change with explicit justification — this step is Core:
|
||||
**do not touch code until the user says "core sisteme ekle"** (rule 3.6).
|
||||
4. Propose minimal code change with explicit justification — this step is out of frame:
|
||||
**ask and wait for approval before touching code** (rule 3.6).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -631,7 +625,7 @@ A proposal is complete only if all are present:
|
|||
4. Menu-route-component mapping included.
|
||||
5. Validation checklist included.
|
||||
6. Risk/rollback notes included.
|
||||
7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Menu}.md` is completed (analysis,
|
||||
7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Module}.md` is completed (analysis,
|
||||
decisions, behaviour) and indexed in `README.md` §10.5.
|
||||
|
||||
---
|
||||
|
|
@ -670,8 +664,8 @@ There are **two seed surfaces**. AI must not mix them.
|
|||
Platform-owned screens, menus, permissions and language texts. Changing them requires a build and
|
||||
a deploy. AI must learn and teach implementation flow primarily from these assets:
|
||||
|
||||
- ListFormSeeder_Sas.cs / ListFormSeeder_Adm.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
|
||||
- configs/seeds/data/Sas.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data)
|
||||
- ListFormSeeder_Saas.cs / ListFormSeeder_Administration.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
|
||||
- configs/seeds/host/data/App.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data)
|
||||
- WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read
|
||||
surface B below
|
||||
- ListFormSeedDataApplier, CrudEndpointSeedApplier (Domain/Data/) — the single home of the apply
|
||||
|
|
@ -684,14 +678,11 @@ a deploy. AI must learn and teach implementation flow primarily from these asset
|
|||
**B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through
|
||||
`SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer, the CRUD
|
||||
Endpoint Manager and every ListForm screen with a `SeedFilePath` write here; the
|
||||
seeders above restore them when the database is recreated. There is a single folder and no
|
||||
tenant scope: the host and every tenant with its own database are seeded from the same files
|
||||
(`SeedFileScope`; a tenant sharing the host database is skipped — the host seed already wrote
|
||||
there). Files are written only in host context; a tenant's record never reaches a seed file.
|
||||
seeders above restore them when the database is recreated. Scope folders mirror the CDN layout:
|
||||
|
||||
```
|
||||
configs/seeds/{wizard,data,crud,docs,sql/{object,execute},postgres/{object,execute}}
|
||||
configs/seeds/{sql,postgres}/host/{object,execute} host database only (e.g. scripts over HangFire)
|
||||
configs/seeds/host/{wizard,data,crud,sql/{object,execute},postgres/{object,execute}}
|
||||
configs/seeds/tenants/{tenantId}/… same layout, tenant scope
|
||||
configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned)
|
||||
```
|
||||
|
||||
|
|
@ -731,13 +722,13 @@ AI must prioritize metadata-based field behavior instead of suggesting custom Re
|
|||
|
||||
### 31.3 Step 3 - Add menu and route mapping
|
||||
|
||||
From the `configs/seeds/data/Sas.Menus.*.json` patterns, AI must define both:
|
||||
From the `configs/seeds/host/data/App.Menus.*.json` patterns, AI must define both:
|
||||
|
||||
- `Sas.Menus.Routes.json` row:
|
||||
- `App.Menus.Routes.json` row:
|
||||
- `Id`, `Key`, `Path`, `ComponentPath`, `RouteType`, `Authority`
|
||||
- for a visitor-facing route, also its `Seo*` columns — there is no separate SEO table
|
||||
(lowcode §0.9)
|
||||
- `Sas.Menus.Menu.json` row:
|
||||
- `App.Menus.Menu.json` row:
|
||||
- `ParentCode`, `Code`, `DisplayName`, `Url`, `Icon`, `RequiredPermissionName`, `Order`
|
||||
|
||||
For dynamic list screens, base route pattern is:
|
||||
|
|
@ -748,7 +739,7 @@ AI must ensure menu URL and route path point to same screen contract.
|
|||
|
||||
### 31.4 Step 4 - Add permissions
|
||||
|
||||
From the `configs/seeds/data/Abp.Identity.Permission*.json` patterns, AI must define:
|
||||
From the `configs/seeds/host/data/Abp.Identity.Permission*.json` patterns, AI must define:
|
||||
|
||||
- Permission group (if missing)
|
||||
- Permission definitions for feature root and actions
|
||||
|
|
@ -768,7 +759,7 @@ AI must never propose menu/route without permission contract.
|
|||
|
||||
### 31.5 Step 5 - Add settings/integration dependencies
|
||||
|
||||
From the `configs/seeds/data/Sas.SettingDefinitions.json` / `Sas.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services:
|
||||
From the `configs/seeds/host/data/App.SettingDefinitions.json` / `App.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services:
|
||||
|
||||
- Sender credentials and endpoints (sms/mail/whatsapp/rocket)
|
||||
- AiBot integration endpoint and activation state
|
||||
|
|
@ -869,10 +860,6 @@ Rules:
|
|||
|
||||
- Do not require user to explicitly request these columns each time.
|
||||
- If user explicitly says "no tenant" or "no audit", then respect that override.
|
||||
- A non-definition (transaction/document) table also gets `BranchId uniqueidentifier NOT NULL`
|
||||
next to `TenantId`, and its screens (including views reading it) use `IsBranch: true`; detail/line
|
||||
tables carry the column too: Branch is picked first and the header lookup cascades on it (`CascadeParentFields: BranchId`), subform relation `BranchId → BranchId`. Override only on "no branch". Details:
|
||||
`lowcode.instructions.md` §8.1 (İşlem tablosu → `BranchId`).
|
||||
- Primary key strategy must remain compatible with existing index/key policy.
|
||||
|
||||
### 32.2 Wizard menu parent fallback behavior
|
||||
|
|
@ -920,8 +907,8 @@ Display names are read from the language key whose name equals the group name.
|
|||
|
||||
### 32.5 Wizard export/import is a package, not a file
|
||||
|
||||
Exporting a wizard produces a zip whose paths are relative to the seed folder and which carries
|
||||
every dependency:
|
||||
Exporting a wizard produces a zip whose paths are relative to the scope folder
|
||||
(`host` / `tenants/{tenantId}`) and which carries every dependency:
|
||||
|
||||
The closure is **recursive** (`WizardExportCollector`): a sub-form's wizard file is queued and its
|
||||
own dependencies are collected under the same rules, so the archive stands on its own.
|
||||
|
|
@ -933,7 +920,7 @@ no `DevRequest` and are exported one by one.
|
|||
|
||||
- `wizard/{file}.json` — the screen itself plus the wizard files of the sub-forms it references
|
||||
through `SubFormsDto[].Code`, recursively
|
||||
- `data/Sas.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the
|
||||
- `data/App.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the
|
||||
exported component and its dependency chain, never exported whole
|
||||
- `data/{ListFormCode}.json` — the screen's own data mirror when `SeedFilePath` is set
|
||||
(an extensionless `SeedFilePath` is the folder form: `data/{ListFormCode}/{key}.json`, one file per row)
|
||||
|
|
@ -965,7 +952,7 @@ up so `RollbackImport` can undo the whole batch), closed by `CompleteImport`.
|
|||
`AnalyzeImport` also **validates** before anything is written (`WizardImportValidator`). With at
|
||||
least one `Error` finding the session is marked `blocked` on the server and `ApplyImport` refuses,
|
||||
so the check cannot be bypassed by calling the endpoint directly. Errors: malformed JSON; a wizard
|
||||
file with no `Wizard` section or, on the List path, no `Data` heading (`SelectCommand`/`KeyFieldName`); a screen's or a `crud/{Entity}.json`'s table missing in
|
||||
file with no `Wizard`/`ListForm` section; a screen's or a `crud/{Entity}.json`'s table missing in
|
||||
the target **and** absent from the archive's scripts; a `data/{code}.json` whose ListForm exists
|
||||
neither in the archive nor in the target; an unresolvable custom-component `Dependencies` /
|
||||
`DataSources[].SeedFile`; an empty component name or route on the `Custom` path; a sub-form
|
||||
|
|
@ -1011,8 +998,7 @@ later steps would fail on it; (b) columns missing from existing tables are added
|
|||
through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows, except the platform files that hold
|
||||
the wizards' menu/permission/language rows (`WizardSeedRowStore.ListFormCodes`); (e) `wizard/*.json`
|
||||
— the screens are **not** installed: like a deploy, the file is marked `Pending` and the next DB
|
||||
Migrate installs it (`WizardDataSeeder`) from the file's `Wizard` section as-is
|
||||
(`Wizard.BuildListForm()/BuildFields()`; the file has no separate `ListForm`/`Fields` sections), even
|
||||
Migrate installs it (`WizardDataSeeder`) from the file's `ListForm`/`Fields` contract as-is, even
|
||||
when the target already has the screen.
|
||||
|
||||
A wizard deploy (wizard screen, MCP `deploy_wizard`, edit) never writes to the database: it only
|
||||
|
|
@ -1022,7 +1008,7 @@ grants the permissions to a role (admin included); roles get them from the role
|
|||
|
||||
Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` /
|
||||
`execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries.
|
||||
Permissions: `Sas.Listforms.Wizard.Export` / `.Import`.
|
||||
Permissions: `App.Listforms.Wizard.Export` / `.Import`.
|
||||
|
||||
AI must not propose moving a screen between environments by copying a single json file.
|
||||
|
||||
|
|
|
|||
23
.github/instructions/dotnet.instructions.md
vendored
23
.github/instructions/dotnet.instructions.md
vendored
|
|
@ -122,20 +122,6 @@ Ek kurallar:
|
|||
- **Repository**: özel sorgu gerekiyorsa Domain'de arayüz + EntityFrameworkCore'da
|
||||
implementasyon. AppService içinde `DbContext` doğrudan kullanılmaz.
|
||||
- **Dinamik SQL**: parametreli çalıştırılır; string birleştirme ile sorgu kurulmaz.
|
||||
- **İki sağlayıcı**: uygulama SQL Server ve PostgreSQL'de çalışır (`DefaultDatabaseProvider`).
|
||||
EF modeli SQL Server sözdizimiyle yazılır (`HasColumnType("nvarchar(max)")`,
|
||||
`HasFilter("[IsDeleted] = 0")`, `HasCheckConstraint`, `HasDefaultValueSql("NEWID()")`);
|
||||
PostgreSQL'de `PlatformDbContext.ConfigureProviderSpecificModel` bunları `PostgreSqlModelSyntax`
|
||||
ile çevirir. Çeviride karşılığı olmayan bir sözdizimi kullanılırsa `PostgreSqlModelSyntax`'a
|
||||
eklenir; immutable olmayan hesaplanan kolon (`GETDATE()`'e bağlı) PG'de yazılamaz, sağlayıcıya
|
||||
göre dallanır. C#'ta üretilen SQL tırnaklı tanımlayıcı ve ANSI ifadelerle yazılır ya da
|
||||
`DataSourceType`'a göre dallanır; yalnızca T-SQL'de çalışan metin eklenmez. Tanımlayıcı
|
||||
doğrulama/tırnaklama ve varsayılan şema tek yerdedir: `Sozsoft.Platform.Data.SqlIdentifier`
|
||||
(Domain.Shared; `IsSafe`, `Quote`, `QuoteQualified`, `DefaultSchema`), nesne kataloğu sorgusu
|
||||
`SqlCatalogQueries`; ikinci bir regex/`"public" : "dbo"` kopyası yazılmaz. Dapper deposu tek
|
||||
gerçeklemedir (`DynamicDataRepository`, sağlayıcı `DbProviderFactory` ile seçilir; `Ms`/`Pg`
|
||||
anahtarlı sınıflar yalnızca DI kaydıdır). `PropertyNameCaseInsensitive` JSON okuması
|
||||
`PlatformJsonOptions.CaseInsensitive` ile yapılır, çağrı başına `JsonSerializerOptions` üretilmez.
|
||||
- **Mapping**: Mapperly (`[Mapper]` partial class) kullanılır; AutoMapper eklenmez.
|
||||
Mapper'lar servis klasöründeki `{Alan}Mappers.cs` dosyasındadır (modülde
|
||||
`{Modül}ApplicationMappers.cs`), ABP'nin `MapperBase<TSource, TDest>` /
|
||||
|
|
@ -146,14 +132,7 @@ Ek kurallar:
|
|||
`[MapperIgnoreTarget(nameof(...))]` ile açıkça yazılır; iç içe koleksiyon için ayrı
|
||||
`private partial` eşleme metodu tanımlanır.
|
||||
- **Localization**: kullanıcıya görünen metin koda gömülmez, `PlatformResource`
|
||||
anahtarı üzerinden verilir. Bu her tür exception mesajı, uyarı listesi, DTO açıklaması ve
|
||||
kayda yazılan sabit değer (ör. sistem onay açıklaması) için geçerlidir; Türkçe ya da
|
||||
İngilizce statik metin backend'den dönmez. Servislerde `L["Sas.X.Y", arg]`; localizer
|
||||
alamayan statik/yardımcı sınıflarda `new BusinessException(PlatformConsts.AppName + ":Sas.X.Y").WithData("name", v)`
|
||||
(ABP kodu `Platform:` ad alanıyla `PlatformResource`'a eşler, dil anahtarı kodun tamamıdır,
|
||||
yer tutucu `{name}`). Log satırları ve geliştiriciye dönük `[Obsolete]` metinleri İngilizcedir,
|
||||
yerelleştirilmez. MCP araç açıklamaları ve `McpProblem` metinleri modele dönüktür, bu kuralın
|
||||
dışındadır; `ModuleDocRenderer`'ın ürettiği modül dokümanı Türkçe proje belgesidir.
|
||||
anahtarı üzerinden verilir.
|
||||
- **Çok değerli alan taşıyan girdi DTO'su**: ekranda `dxTagBox` (ya da çoklu seçimli
|
||||
`dxGridBox`) ile doldurulan bir alan tipli bir DTO'ya bağlanıyorsa property `string`
|
||||
kalır ve `[JsonConverter(typeof(MultiValueStringJsonConverter))]` ile işaretlenir —
|
||||
|
|
|
|||
47
.github/instructions/list.instructions.md
vendored
47
.github/instructions/list.instructions.md
vendored
|
|
@ -16,9 +16,9 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
- **Modularization:** Each module (e.g., {Modul}) must have its own seeder, permission group, and localization entries.
|
||||
- **Naming Conventions:**
|
||||
- Table names: `{Modul}_T_{Entity}` (e.g., `Mrp_T_Order`)
|
||||
- Menu/permission keys: `{Modul}`, `{Modul}.{Liste}`
|
||||
- Menu/permission keys: `App.{Modul}`, `App.{Modul}.{Liste}`
|
||||
- **Tenant Isolation:** All data, forms, and permissions must be tenant-aware.
|
||||
- **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json`.
|
||||
- **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/host/data/App.Languages.LanguageText.json`.
|
||||
- **No Redundant Code:** Avoid duplicating permission grants or seeding logic across modules.
|
||||
- **SeedConsts Rule:** For every new module/entity, add a constant to `SeedConsts` (e.g., `public static class {Modul}` and `public const string {Liste}`) if it does not already exist. Never duplicate existing constants.
|
||||
- **DeleteCommand Rule:** In all `ListFormSeeder_{Modul}.cs` files, the `DeleteCommand` property **must** use the `DefaultDeleteCommand` function, e.g., `DeleteCommand = DefaultDeleteCommand("{TabloAdı}")`. Never use a raw SQL string directly for `DeleteCommand`.
|
||||
|
|
@ -27,30 +27,27 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
|
||||
## Special File Handling Rules
|
||||
|
||||
- **configs/seeds/data/Sas.Menus.*.json:**
|
||||
- Never add, remove or re-path a route in `Sas.Menus.Routes.json`. The only field set you may edit on an existing row is its `Seo*` columns (lowcode §0.9). Only `Sas.Menus.Module.json` and `Sas.Menus.Menu.json` can be changed for menu additions or updates. All menu additions must use the platform's configuration mechanisms or be added to those two files as required by platform design.
|
||||
- **configs/seeds/host/data/App.Menus.*.json:**
|
||||
- Never add, remove or re-path a route in `App.Menus.Routes.json`. The only field set you may edit on an existing row is its `Seo*` columns (lowcode §0.9). Only `App.Menus.Module.json` and `App.Menus.Menu.json` can be changed for menu additions or updates. All menu additions must use the platform's configuration mechanisms or be added to those two files as required by platform design.
|
||||
|
||||
- **configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json:**
|
||||
- **configs/seeds/host/data/App.Languages.LanguageText.json:**
|
||||
- When adding a new key, always check if the key already exists. Only add the key if it does not exist to prevent duplicates.
|
||||
- The data lives in the folder `configs/seeds/data/Sas.Languages.LanguageText/`: one file per language (`ar, de-DE, en, es, fi, fr, hi, hr, hu, it, pt-BR, ru, sk, sl, tr, zh-Hans`, i.e. every `CultureName` of `Sas.Languages.Language.json`). The file name is the language's `CultureName` (`{CultureName}.json`: `tr.json`, `de-DE.json`) and its `Rows` hold all rows of that language.
|
||||
- Wizard, MCP and every component/screen/menu addition follow the same rule: the generated EN + TR rows are written by the deploy/seeder, the producer then writes the translated rows for all other languages (`lowcode.instructions.md` §4.4.3, CLAUDE.md standing default 27). Deleting a key removes its row from every file.
|
||||
- Every new key is added to **all** language files (one row per file). `en` and `tr` carry the authored text; the other 14 files get its translation. When a language is added to `Sas.Languages.Language.json`, create its file and translate every key.
|
||||
- Each row's `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID.
|
||||
- Each key is two rows (`CultureName` `en` and `tr`). `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID.
|
||||
|
||||
---
|
||||
|
||||
## File-by-File Change Summary
|
||||
|
||||
|
||||
### 1. configs/seeds/data/Sas.Menus.Menu.json
|
||||
- **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `Sas.Menus.Module.json`.
|
||||
### 1. configs/seeds/host/data/App.Menus.Menu.json
|
||||
- **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `App.Menus.Module.json`.
|
||||
- **{Liste}** added as a child menu under {Modul}.
|
||||
|
||||
### 2. configs/seeds/data/Abp.Identity.Permissions.json
|
||||
- **{Modul} permissions** grouped under `{Modul}`.
|
||||
### 2. configs/seeds/host/data/Abp.Identity.Permissions.json
|
||||
- **{Modul} permissions** grouped under `App.{Modul}`.
|
||||
- **{Liste} permissions** nested under {Modul} group.
|
||||
|
||||
### 3. ListFormSeeder_Adm.cs
|
||||
### 3. ListFormSeeder_Administration.cs
|
||||
- **Removed** {Liste} seeding logic (moved to {Modul} seeder).
|
||||
|
||||
### 4. ListFormSeeder_{Modul}.cs
|
||||
|
|
@ -64,7 +61,7 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
### 6. PlatformIdentityDataSeeder.cs
|
||||
- **Removed** redundant {Modul} permission grant logic (now handled by the permission seed data).
|
||||
|
||||
### 7. Sas.Languages.LanguageText/{CultureName}.json
|
||||
### 7. App.Languages.LanguageText.json
|
||||
- **Added** localization entries for:
|
||||
- {Modul} menu and {Liste} list
|
||||
- {Liste} list fields (e.g., `FullName`, `Phone`)
|
||||
|
|
@ -77,11 +74,11 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
### 1. Add a New Module (e.g., {Modul})
|
||||
```
|
||||
Yeni bir modül ekle (ör: {Modul}):
|
||||
- configs/seeds/data/Sas.Menus.Menu.json'a kök menü, Sas.Menus.Module.json'a modül kaydı olarak ekle
|
||||
- configs/seeds/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur
|
||||
- configs/seeds/host/data/App.Menus.Menu.json'a kök menü, App.Menus.Module.json'a modül kaydı olarak ekle
|
||||
- configs/seeds/host/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur
|
||||
- ListFormSeeder_{Modul}.cs dosyası oluştur ve list-form seed'ini buraya taşı
|
||||
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak adlandır
|
||||
- configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına menü, liste ve alan çevirilerini ekle
|
||||
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına menü, liste ve alan çevirilerini ekle
|
||||
```
|
||||
|
||||
### 2. Add a New List Under a Module
|
||||
|
|
@ -90,8 +87,8 @@ Yeni bir liste ekle (ör: {Liste}):
|
|||
- {Modul} ana menüsünün altına ekle
|
||||
- ListFormSeeder_{Modul}.cs dosyasına seed kodunu ekle
|
||||
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak oluştur
|
||||
- configs/seeds/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle
|
||||
- configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına liste ve alan çevirilerini ekle
|
||||
- configs/seeds/host/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle
|
||||
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına liste ve alan çevirilerini ekle
|
||||
```
|
||||
|
||||
### 3. Enforce Tenant Isolation and Full Audit
|
||||
|
|
@ -104,10 +101,10 @@ Tablo ve list-form için tenant izolasyonu ve full-audit alanlarını ekle:
|
|||
|
||||
### 4. Add/Update Localization
|
||||
```
|
||||
Yeni menü, liste veya alan eklediğinde configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json klasöründeki tüm dil dosyalarına (EN + TR yazılan metin, diğer diller çeviri) şu şekilde ekle:
|
||||
- {Modul}
|
||||
- {Modul}.{Liste}
|
||||
- Sas.Listform.ListformField.{Alan}
|
||||
Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.LanguageText.json dosyasına (EN + TR satırı) şu şekilde ekle:
|
||||
- App.{Modul}
|
||||
- App.{Modul}.{Liste}
|
||||
- App.Listform.ListformField.{Alan}
|
||||
```
|
||||
|
||||
### 5. SeedConsts and DeleteCommand Rules
|
||||
|
|
@ -131,7 +128,7 @@ Yeni menü, liste veya alan eklediğinde configs/seeds/data/Sas.Languages.Langua
|
|||
- [ ] Menü ve izinler doğru hiyerarşide mi?
|
||||
- [ ] List-form seed'leri doğru modül dosyasında mı?
|
||||
- [ ] Tablo isimleri ve referansları standartlara uygun mu? (örn: {Modul}_T_{Entity})
|
||||
- [ ] Sas.Languages.LanguageText/{CultureName}.json'da tüm yeni menü, liste ve alanlar var mı?
|
||||
- [ ] App.Languages.LanguageText.json'da tüm yeni menü, liste ve alanlar var mı?
|
||||
- [ ] Redundant kod veya izin grant'ı var mı?
|
||||
- [ ] Seed ve migration sonrası UI'da çeviriler doğru görünüyor mu?
|
||||
- [ ] SeedConsts.cs dosyasında ilgili sabitler var mı, tekrar eklenmemiş mi?
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1944
.github/instructions/lowcode.instructions.md
vendored
1944
.github/instructions/lowcode.instructions.md
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -6,15 +6,4 @@
|
|||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
ABP paketleri System.Security.Cryptography.Pkcs 10.0.0'ı geçişli olarak getiriyor (GHSA-846f-f764-76mx,
|
||||
NU1903). Paket çerçeveden budanmadığı için yamalı sürüm bütün projelere tek yerden verilir; ABP yamalı
|
||||
sürümü kendisi çekince satır kaldırılır (dotnet list package - -vulnerable - -include-transitive).
|
||||
Projelerin kendi doğrudan referansı olan paketler (MessagePack, Cryptography.Xml) kendi csproj'larında güncel tutulur.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ username=system%40sozsoft.com
|
|||
- WhatsApp -> API Setup kısmından "From" kısmında yeni telefon numarası eklenir.
|
||||
- WhatsApp -> API Setup kısmından Generate Access Token oluşturulur.
|
||||
|
||||
- token: <access token>
|
||||
- token: EAASM83pDJf0BO0ZCYBr3Fx4SES0ox8XiZCgP3FIteprYpwawZCkGYjpMlKk4OZCWgRNZC2Ttofgf8amMf929ZBuDcgYIOsXkPMGBNmVxm2czipzq63LmtbYfyCWsKVy0q1jtay0nRoeDuO2FKhqCfLLtNn9cSWjINVLcz26ptK8a2Oko83cZBzrNsp3cpIECnzqRfEjHdEz1U73y3ZB1MZBOhHilax7yZALf4ZA1Tmk
|
||||
- WhatsApp telefon numarasını Register etmek için aşağıdaki postman Curl gönderilir.
|
||||
|
||||
```
|
||||
curl --location 'https://graph.facebook.com/v21.0/<phone-number-id>/register' \
|
||||
curl --location 'https://graph.facebook.com/v21.0/521106361082067/register' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Authorization: Bearer <access token>' \
|
||||
--data '{
|
||||
|
|
@ -68,7 +68,7 @@ username=system%40sozsoft.com
|
|||
}'
|
||||
```
|
||||
|
||||
- Message göndermek için url : https://graph.facebook.com/v21.0/<phone-number-id>/messages
|
||||
- Message göndermek için url : https://graph.facebook.com/v21.0/521106361082067/messages
|
||||
|
||||
```
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ COPY "src/Sozsoft.Platform.Domain/Sozsoft.Platform.Domain.csproj" "src/Sozsoft.P
|
|||
COPY "src/Sozsoft.Platform.Domain.Shared/Sozsoft.Platform.Domain.Shared.csproj" "src/Sozsoft.Platform.Domain.Shared/"
|
||||
COPY "src/Sozsoft.Platform.EntityFrameworkCore/Sozsoft.Platform.EntityFrameworkCore.csproj" "src/Sozsoft.Platform.EntityFrameworkCore/"
|
||||
COPY "src/Sozsoft.Platform.HttpApi/Sozsoft.Platform.HttpApi.csproj" "src/Sozsoft.Platform.HttpApi/"
|
||||
COPY "src/Sozsoft.Platform.HttpApi.Client/Sozsoft.Platform.HttpApi.Client.csproj" "src/Sozsoft.Platform.HttpApi.Client/"
|
||||
COPY "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" "src/Sozsoft.Platform.HttpApi.Host/"
|
||||
COPY "src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj" "src/Sozsoft.Platform.DbMigrator/"
|
||||
COPY "test/Sozsoft.Platform.EntityFrameworkCore.Tests/Sozsoft.Platform.EntityFrameworkCore.Tests.csproj" "test/Sozsoft.Platform.EntityFrameworkCore.Tests/"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.Applicatio
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi", "src\Sozsoft.Platform.HttpApi\Sozsoft.Platform.HttpApi.csproj", "{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi.Client", "src\Sozsoft.Platform.HttpApi.Client\Sozsoft.Platform.HttpApi.Client.csproj", "{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.DbMigrator", "src\Sozsoft.Platform.DbMigrator\Sozsoft.Platform.DbMigrator.csproj", "{AA94D832-1CCC-4715-95A9-A483F23A1A5D}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi.Host", "src\Sozsoft.Platform.HttpApi.Host\Sozsoft.Platform.HttpApi.Host.csproj", "{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D}"
|
||||
|
|
@ -184,6 +186,18 @@ Global
|
|||
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -579,6 +593,7 @@ Global
|
|||
{42F719ED-8413-4895-B5B4-5AB56079BC66} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{520659C8-C734-4298-A3DA-B539DB9DFC0B} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{AA94D832-1CCC-4715-95A9-A483F23A1A5D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{4D886EEF-D4CF-4879-A10D-249D762E4ED9} = {03E1C8DA-035E-4882-AF81-F392139FCF38}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
using System.Threading.Tasks;
|
||||
using Volo.Abp;
|
||||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[IntegrationService]
|
||||
public interface ILanguageKeyIntegrationService
|
||||
{
|
||||
Task CreateKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey
|
||||
);
|
||||
|
||||
Task DeleteKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey);
|
||||
}
|
||||
|
||||
|
|
@ -6,25 +6,6 @@ public class LanguagesPermissions
|
|||
{
|
||||
public const string GroupName = "Languages";
|
||||
|
||||
// Yetki tanımları seed'dedir (data/Abp.Identity.Permissions.json); burada yalnızca adları durur.
|
||||
// Değerler PlatformConsts.AppCodes.Languages ile aynıdır — modül platforma referans vermez.
|
||||
public static class Language
|
||||
{
|
||||
public const string Default = "Sas.Languages.Language";
|
||||
public const string Create = Default + ".Create";
|
||||
public const string Update = Default + ".Update";
|
||||
public const string Delete = Default + ".Delete";
|
||||
}
|
||||
|
||||
/// <summary>Dil anahtarları ayrı bir ekranda değil, dil metinleriyle birlikte yönetilir.</summary>
|
||||
public static class LanguageText
|
||||
{
|
||||
public const string Default = "Sas.Languages.LanguageText";
|
||||
public const string Create = Default + ".Create";
|
||||
public const string Update = Default + ".Update";
|
||||
public const string Delete = Default + ".Delete";
|
||||
}
|
||||
|
||||
public static string[] GetAll()
|
||||
{
|
||||
return ReflectionHelper.GetPublicConstantsRecursively(typeof(LanguagesPermissions));
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Entities;
|
||||
|
|
@ -14,7 +12,6 @@ using Volo.Abp.Validation;
|
|||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[Authorize]
|
||||
public class LanguageAppService : CrudAppService<
|
||||
Language,
|
||||
LanguageDto,
|
||||
|
|
@ -26,11 +23,6 @@ public class LanguageAppService : CrudAppService<
|
|||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
ObjectMapperContext = typeof(LanguagesApplicationModule);
|
||||
GetPolicyName = LanguagesPermissions.Language.Default;
|
||||
GetListPolicyName = LanguagesPermissions.Language.Default;
|
||||
CreatePolicyName = LanguagesPermissions.Language.Create;
|
||||
UpdatePolicyName = LanguagesPermissions.Language.Update;
|
||||
DeletePolicyName = LanguagesPermissions.Language.Delete;
|
||||
}
|
||||
|
||||
public override async Task<LanguageDto> CreateAsync(LanguageDto input)
|
||||
|
|
@ -48,7 +40,6 @@ public class LanguageAppService : CrudAppService<
|
|||
return await base.CreateAsync(input);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.Language.Default)]
|
||||
public async Task<LanguageDto> GetLanguageByCultureNameAsync(string CultureName)
|
||||
{
|
||||
var item = await Repository.FirstOrDefaultAsync(a => a.CultureName == CultureName);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
|
@ -13,7 +11,6 @@ using Volo.Abp.Validation;
|
|||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[Authorize]
|
||||
public class LanguageKeyAppService : CrudAppService<
|
||||
LanguageKey,
|
||||
LanguageKeyDto,
|
||||
|
|
@ -29,11 +26,6 @@ public class LanguageKeyAppService : CrudAppService<
|
|||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
ObjectMapperContext = typeof(LanguagesApplicationModule);
|
||||
GetPolicyName = LanguagesPermissions.LanguageText.Default;
|
||||
GetListPolicyName = LanguagesPermissions.LanguageText.Default;
|
||||
CreatePolicyName = LanguagesPermissions.LanguageText.Create;
|
||||
UpdatePolicyName = LanguagesPermissions.LanguageText.Update;
|
||||
DeletePolicyName = LanguagesPermissions.LanguageText.Delete;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[IntegrationService]
|
||||
public class LanguageKeyIntegrationService : ILanguageKeyIntegrationService, ITransientDependency
|
||||
{
|
||||
private readonly IRepository<LanguageKey, Guid> _repository;
|
||||
|
||||
public LanguageKeyIntegrationService(IRepository<LanguageKey, Guid> repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task CreateKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey
|
||||
)
|
||||
{
|
||||
//NameKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = NameKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//DescriptionKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = DescriptionKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//MainGroupKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = MainGroupKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//SubGroupKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = SubGroupKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey)
|
||||
{
|
||||
await _repository.DeleteAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4,12 +4,11 @@ using System.ComponentModel.DataAnnotations;
|
|||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sozsoft.Languages.Caching;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using StackExchange.Redis;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
|
@ -20,11 +19,6 @@ using Volo.Abp.Validation;
|
|||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
/// <summary>
|
||||
/// Dil metinlerinin yönetim uçları. Çeviri okuma bu servisten geçmez — public sayfalar dahil bütün
|
||||
/// istemciler metinleri ABP'nin application-localization ucundan (DatabaseResourceLocalizer) alır.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class LanguageTextAppService : CrudAppService<
|
||||
LanguageText,
|
||||
LanguageTextDto,
|
||||
|
|
@ -35,25 +29,20 @@ public class LanguageTextAppService : CrudAppService<
|
|||
{
|
||||
private readonly IRepository<LanguageText, Guid> _repositoryText;
|
||||
private readonly IRepository<LanguageKey, Guid> _repositoryKey;
|
||||
private readonly DistributedCacheCleaner _cacheCleaner;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public LanguageTextAppService(
|
||||
IRepository<LanguageText, Guid> repositoryText,
|
||||
IRepository<LanguageKey, Guid> repositoryKey,
|
||||
DistributedCacheCleaner cacheCleaner
|
||||
IConfiguration configuration
|
||||
)
|
||||
: base(repositoryText)
|
||||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
ObjectMapperContext = typeof(LanguagesApplicationModule);
|
||||
GetPolicyName = LanguagesPermissions.LanguageText.Default;
|
||||
GetListPolicyName = LanguagesPermissions.LanguageText.Default;
|
||||
CreatePolicyName = LanguagesPermissions.LanguageText.Create;
|
||||
UpdatePolicyName = LanguagesPermissions.LanguageText.Update;
|
||||
DeletePolicyName = LanguagesPermissions.LanguageText.Delete;
|
||||
_repositoryText = repositoryText;
|
||||
_repositoryKey = repositoryKey;
|
||||
_cacheCleaner = cacheCleaner;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input)
|
||||
|
|
@ -74,7 +63,6 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return await base.CreateAsync(input);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Update)]
|
||||
public async Task UpdateLanguageTextAsync(LanguageTextCreateUpdateDto input)
|
||||
{
|
||||
var entity = await _repositoryText.GetAsync(
|
||||
|
|
@ -89,7 +77,6 @@ public class LanguageTextAppService : CrudAppService<
|
|||
}
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<PagedResultDto<LanguageTextTranslatedDto>> GetLanguageTextTranslatedAsync(LanguageTextTranslatedRequestDto input)
|
||||
{
|
||||
var query = await _repositoryKey.GetQueryableAsync();
|
||||
|
|
@ -122,7 +109,6 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return new PagedResultDto<LanguageTextTranslatedDto>(totalCount, dtoLanguageTextTranslate);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<LanguageTextDto> GetLanguageTextAsync(LanguageTextFilteredDto input)
|
||||
{
|
||||
var entity = await _repositoryText.FirstOrDefaultAsync(a =>
|
||||
|
|
@ -133,7 +119,6 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return ObjectMapper.Map<LanguageText, LanguageTextDto>(entity);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<List<LanguageTextDto>> GetLanguageTextByCultureNameAsync(string CultureName)
|
||||
{
|
||||
var item = await _repositoryText.GetListAsync(a => a.CultureName == CultureName);
|
||||
|
|
@ -145,11 +130,11 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return ObjectMapper.Map<List<LanguageText>, List<LanguageTextDto>>(item);
|
||||
}
|
||||
|
||||
// Dil Metinleri listesindeki "Redis önbelleğini temizle" butonu da bu yetkiyle görünür.
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task ClearRedisCacheAsync()
|
||||
{
|
||||
await _cacheCleaner.ClearAsync();
|
||||
var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!);
|
||||
var db = redis.GetDatabase();
|
||||
await db.ExecuteAsync("FLUSHALL");
|
||||
}
|
||||
|
||||
protected virtual IQueryable<LanguageKey> ApplySorting(IQueryable<LanguageKey> query, LanguageTextTranslatedRequestDto input)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ public partial class LanguageToLanguageInfoMapper : MapperBase<Language, Languag
|
|||
public override partial LanguageInfo Map(Language source);
|
||||
|
||||
public override partial void Map(Language source, LanguageInfo destination);
|
||||
|
||||
public override void BeforeMap(Language source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(Language source, LanguageInfo destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -19,6 +27,14 @@ public partial class LanguageToLanguageDtoMapper : MapperBase<Language, Language
|
|||
public override partial LanguageDto Map(Language source);
|
||||
|
||||
public override partial void Map(Language source, LanguageDto destination);
|
||||
|
||||
public override void BeforeMap(Language source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(Language source, LanguageDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -27,6 +43,14 @@ public partial class LanguageKeyToLanguageKeyDtoMapper : MapperBase<LanguageKey,
|
|||
public override partial LanguageKeyDto Map(LanguageKey source);
|
||||
|
||||
public override partial void Map(LanguageKey source, LanguageKeyDto destination);
|
||||
|
||||
public override void BeforeMap(LanguageKey source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageKey source, LanguageKeyDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -35,6 +59,14 @@ public partial class LanguageTextToLanguageTextDtoMapper : MapperBase<LanguageTe
|
|||
public override partial LanguageTextDto Map(LanguageText source);
|
||||
|
||||
public override partial void Map(LanguageText source, LanguageTextDto destination);
|
||||
|
||||
public override void BeforeMap(LanguageText source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageText source, LanguageTextDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -43,6 +75,14 @@ public partial class LanguageTextToLanguageTextEtoMapper : MapperBase<LanguageTe
|
|||
public override partial LanguageTextEto Map(LanguageText source);
|
||||
|
||||
public override partial void Map(LanguageText source, LanguageTextEto destination);
|
||||
|
||||
public override void BeforeMap(LanguageText source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageText source, LanguageTextEto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -53,6 +93,14 @@ public partial class LanguageTextCreateUpdateDtoToLanguageTextMapper : MapperBas
|
|||
|
||||
[MapperIgnoreTarget(nameof(LanguageText.LanguageKey))]
|
||||
public override partial void Map(LanguageTextCreateUpdateDto source, LanguageText destination);
|
||||
|
||||
public override void BeforeMap(LanguageTextCreateUpdateDto source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageTextCreateUpdateDto source, LanguageText destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -85,4 +133,12 @@ public partial class LanguageKeyToLanguageTextTranslatedDtoMapper : MapperBase<L
|
|||
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreationTime))]
|
||||
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreatorId))]
|
||||
public override partial void Map(LanguageKey source, LanguageTextTranslatedDto destination);
|
||||
|
||||
public override void BeforeMap(LanguageKey source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageKey source, LanguageTextTranslatedDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
namespace Sozsoft.Languages.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Uygulamanın dağıtık önbelleğini boşaltır. <c>FLUSHALL</c> yerine yalnızca bu uygulamanın anahtarlarını
|
||||
/// siler: Redis başka bir uygulama ya da ortamla paylaşılıyorsa onların verisine dokunulmaz.
|
||||
/// Bağlantı süreç başına tektir; her çağrıda yeni bağlantı açmak bağlantı sızdırıyordu.
|
||||
/// </summary>
|
||||
public class DistributedCacheCleaner : ISingletonDependency, IDisposable
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private readonly Lazy<ConnectionMultiplexer> _connection;
|
||||
private readonly string[] _keyPatterns;
|
||||
private readonly IDatabaseResourceLocalizer _localizer;
|
||||
|
||||
public DistributedCacheCleaner(
|
||||
IConfiguration configuration,
|
||||
IOptions<AbpDistributedCacheOptions> cacheOptions,
|
||||
IDatabaseResourceLocalizer localizer)
|
||||
{
|
||||
_localizer = localizer;
|
||||
_connection = new Lazy<ConnectionMultiplexer>(
|
||||
() => ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]!));
|
||||
|
||||
// ABP iki biçimde yazar: IDistributedCache<T> anahtarı [t:{kiracı},]c:{önbellek},k:{KeyPrefix}{anahtar},
|
||||
// bellek içi tanım önbelleklerinin damgası ise doğrudan {KeyPrefix}_Abp...Stamp. Host "Platform",
|
||||
// DbMigrator "Platform:" öneki kullanır; ':' atılınca desenler iki sürecin anahtarlarını da kapsar.
|
||||
var prefix = (cacheOptions.Value.KeyPrefix ?? string.Empty).TrimEnd(':');
|
||||
_keyPatterns = [$"{prefix}*", $"*,k:{prefix}*"];
|
||||
}
|
||||
|
||||
/// <returns>Silinen anahtar sayısı.</returns>
|
||||
public async Task<long> ClearAsync()
|
||||
{
|
||||
var connection = _connection.Value;
|
||||
var database = connection.GetDatabase();
|
||||
long removed = 0;
|
||||
|
||||
foreach (var endpoint in connection.GetEndPoints())
|
||||
{
|
||||
var server = connection.GetServer(endpoint);
|
||||
if (server.IsReplica || !server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pattern in _keyPatterns)
|
||||
{
|
||||
var batch = new List<RedisKey>(BatchSize);
|
||||
await foreach (var key in server.KeysAsync(database.Database, pattern, BatchSize))
|
||||
{
|
||||
batch.Add(key);
|
||||
if (batch.Count == BatchSize)
|
||||
{
|
||||
removed += await DeleteAsync(database, batch);
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
removed += await DeleteAsync(database, batch);
|
||||
}
|
||||
}
|
||||
|
||||
// Dil sözlüğünün süreç içi kopyası Redis'te değildir; temizlik onu da kapsar.
|
||||
_localizer.ClearLocalCache();
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
// Anahtarlar tek tek silinir: çok anahtarlı DEL, cluster'da farklı slot'lara düşen anahtarları reddeder.
|
||||
private static async Task<long> DeleteAsync(IDatabase database, List<RedisKey> keys)
|
||||
{
|
||||
var results = await Task.WhenAll(keys.Select(key => database.KeyDeleteAsync(key)));
|
||||
return results.Count(deleted => deleted);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connection.IsValueCreated)
|
||||
{
|
||||
_connection.Value.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities.Events;
|
||||
|
|
@ -13,12 +12,10 @@ public class LanguageTextCacheItemInvalidator :
|
|||
ITransientDependency
|
||||
{
|
||||
private readonly IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly IDatabaseResourceLocalizer localizer;
|
||||
|
||||
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache, IDatabaseResourceLocalizer localizer)
|
||||
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache)
|
||||
{
|
||||
this.cache = cache;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData)
|
||||
|
|
@ -26,7 +23,7 @@ public class LanguageTextCacheItemInvalidator :
|
|||
//distributedCacheOptions.Value.KeyPrefix
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.Entity.ResourceName, eventData.Entity.CultureName);
|
||||
await cache.RemoveAsync(key);
|
||||
localizer.ClearLocalCache();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities.Events.Distributed;
|
||||
|
|
@ -14,14 +13,11 @@ public class LanguageTextCacheItemInvalidatorDistributed :
|
|||
ITransientDependency
|
||||
{
|
||||
private readonly IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly IDatabaseResourceLocalizer localizer;
|
||||
|
||||
public LanguageTextCacheItemInvalidatorDistributed(
|
||||
IDistributedCache<LanguageTextCacheItem> cache,
|
||||
IDatabaseResourceLocalizer localizer)
|
||||
IDistributedCache<LanguageTextCacheItem> cache)
|
||||
{
|
||||
this.cache = cache;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
public async Task HandleEventAsync(EntityCreatedEto<LanguageTextEto> eventData)
|
||||
|
|
@ -43,7 +39,6 @@ public class LanguageTextCacheItemInvalidatorDistributed :
|
|||
{
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.ResourceName, eventData.CultureName);
|
||||
await cache.RemoveAsync(key);
|
||||
localizer.ClearLocalCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -15,14 +14,8 @@ namespace Sozsoft.Languages.Localization;
|
|||
|
||||
public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonDependency
|
||||
{
|
||||
// Her metin aramasında sözlüğün tamamı Redis'ten çekilip çözülüyordu. Süreç içi katman bunu önler.
|
||||
// Kısa ömürlüdür: metinlerin bir kısmı ham SQL ile yazılır (olay üretmez) ve diğer API örnekleri ile
|
||||
// DbMigrator ayrı süreçtir; onların temizliği buraya en geç bu süre sonunda yansır.
|
||||
private static readonly long LocalCacheDurationMs = (long)TimeSpan.FromSeconds(30).TotalMilliseconds;
|
||||
|
||||
private readonly IRepository<LanguageText, Guid> languageTextRepository;
|
||||
private readonly IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly ConcurrentDictionary<string, (LanguageTextCacheItem Item, long ExpiresAt)> localCache = new();
|
||||
|
||||
public DatabaseResourceLocalizer(
|
||||
IRepository<LanguageText, Guid> languageTextRepository,
|
||||
|
|
@ -34,52 +27,27 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
|
|||
|
||||
public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name)
|
||||
{
|
||||
var value = GetCacheItem(resource, cultureName)?.Dictionary?.GetOrDefault(name);
|
||||
return value == null ? null : new LocalizedString(name, value);
|
||||
var item = GetCacheItem(resource, cultureName);
|
||||
var value = item.Dictionary?.GetOrDefault(name);
|
||||
if (item == null || value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new LocalizedString(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Süreç içi katmanı boşaltır; dağıtık önbellek temizlendiğinde ve metin değiştiğinde çağrılır.</summary>
|
||||
public void ClearLocalCache() => localCache.Clear();
|
||||
|
||||
protected LanguageTextCacheItem GetCacheItem(LocalizationResourceBase resource, string cultureName)
|
||||
{
|
||||
// culture, resource, key
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
|
||||
if (TryGetLocal(key, out var local))
|
||||
{
|
||||
return local;
|
||||
}
|
||||
|
||||
return SetLocal(key, cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName)));
|
||||
}
|
||||
|
||||
private bool TryGetLocal(string key, out LanguageTextCacheItem item)
|
||||
{
|
||||
if (localCache.TryGetValue(key, out var entry) && entry.ExpiresAt > Environment.TickCount64)
|
||||
{
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
|
||||
item = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private LanguageTextCacheItem SetLocal(string key, LanguageTextCacheItem item)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
localCache[key] = (item, Environment.TickCount64 + LocalCacheDurationMs);
|
||||
}
|
||||
|
||||
return item;
|
||||
return cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName));
|
||||
}
|
||||
|
||||
public void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
|
||||
{
|
||||
var item = GetCacheItem(resource, cultureName);
|
||||
|
||||
foreach (var kvp in item?.Dictionary ?? [])
|
||||
foreach (var kvp in item.Dictionary)
|
||||
{
|
||||
dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
|
@ -99,12 +67,9 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
|
|||
public async Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
|
||||
{
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
|
||||
if (!TryGetLocal(key, out var item))
|
||||
{
|
||||
item = SetLocal(key, await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName)));
|
||||
}
|
||||
var item = await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName));
|
||||
|
||||
foreach (var kvp in item?.Dictionary ?? [])
|
||||
foreach (var kvp in item.Dictionary)
|
||||
{
|
||||
dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,5 @@ public interface IDatabaseResourceLocalizer
|
|||
void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary);
|
||||
Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary);
|
||||
LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name);
|
||||
void ClearLocalCache();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ public abstract class MailQueueAppService : ApplicationService
|
|||
{
|
||||
protected MailQueueAppService()
|
||||
{
|
||||
LocalizationResource = typeof(SozsoftMailQueueResource);
|
||||
ObjectMapperContext = typeof(SozsoftMailQueueModule);
|
||||
LocalizationResource = typeof(ErpMailQueueResource);
|
||||
ObjectMapperContext = typeof(ErpMailQueueModule);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
namespace Sozsoft.MailQueue.Application;
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
namespace Sozsoft.MailQueue.Domain.Shared;
|
||||
|
||||
[LocalizationResourceName(MailQueueConsts.AppName)]
|
||||
public class SozsoftMailQueueResource
|
||||
public class ErpMailQueueResource
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
namespace Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
|
||||
public interface IAttachmentExtractor
|
||||
{
|
||||
IList<AttachmentModel> Extract(string attachment, string attachmentParametre);
|
||||
}
|
||||
|
||||
public class AttachmentExtractor : IAttachmentExtractor, ITransientDependency
|
||||
{
|
||||
protected readonly IParameterExtractor ParameterExtractor;
|
||||
|
||||
public AttachmentExtractor(
|
||||
IParameterExtractor parameterExtractor)
|
||||
{
|
||||
ParameterExtractor = parameterExtractor;
|
||||
}
|
||||
|
||||
public IList<AttachmentModel> Extract(string attachment, string attachmentParametre)
|
||||
{
|
||||
var result = new List<AttachmentModel>();
|
||||
|
||||
if (attachment.IsNullOrWhiteSpace())
|
||||
return result;
|
||||
|
||||
var parameters = ParameterExtractor.Extract(attachmentParametre);
|
||||
/*
|
||||
Dict<
|
||||
0, Dict<
|
||||
Tarih, 1232
|
||||
No 4334
|
||||
>
|
||||
1, Dict<
|
||||
QUERY, ghghg
|
||||
QUERYSQL select ffddf
|
||||
*/
|
||||
//Attachment:
|
||||
//{0:MRP:rpt_DepoCikisCekiListesi:PDF:MailEkteGozukecekAd};{1:CRM:rpt_DepoCikisCekiListesiCarpet:XLS:MailEkteGozukecekAd}
|
||||
//AttachmentParametre:
|
||||
//Filtre değerleri olabilir {0:Tarih=1232&No=4334};{1:Tarih=213}
|
||||
//Veya query olabilir {0:QUERY=…&QUERYSQL=..};{1:QUERY=…&QUERYSQL=..}
|
||||
foreach (var item in attachment.Split("};{"))
|
||||
{
|
||||
var keyValue = item.Trim('{').Trim('}').Split(":");
|
||||
if (keyValue.Length == 5)
|
||||
{
|
||||
result.Add(new AttachmentModel
|
||||
{
|
||||
Index = keyValue[0],
|
||||
DbAdi = keyValue[1],
|
||||
RaporAdi = keyValue[2],
|
||||
ExportDosyaTipi = keyValue[3],
|
||||
Filtreler = parameters.GetOrDefault(keyValue[0]),
|
||||
MailEkAdi = keyValue[4]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,36 +2,85 @@
|
|||
using Sozsoft.MailQueue.FileGeneration;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Guids;
|
||||
using Volo.Abp.TextTemplating;
|
||||
|
||||
namespace Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
|
||||
public interface IAttachmentGenerator
|
||||
{
|
||||
Task<Dictionary<string, string>> GenerateAsync(
|
||||
Guid kuyrukId,
|
||||
string attachment,
|
||||
string attachmentParametre,
|
||||
string jobId = null);
|
||||
|
||||
Task<Dictionary<string, string>> GenerateAsync(List<MailTemplateTableModel> tables, Guid? kuyrukId = null, string jobId = null);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kuyruk satırındaki tablo şablonlarından (<c>DosyaTipi</c> PDF/XLS/TXT) mail eki üretir.
|
||||
/// Ek üretiminin tek yolu budur; eski <c>Attachment</c>/<c>AttachmentParameter</c> kolonlarıyla
|
||||
/// MS Access raporundan ek üretme yolu hiç gerçeklenmediği için kaldırıldı — kolon dolu olsa da
|
||||
/// satır artık atlanmaz, tablo ekleriyle gönderilir.
|
||||
/// </summary>
|
||||
public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
||||
{
|
||||
public IParameterExtractor ParameterExtractor { get; }
|
||||
public IAttachmentExtractor AttachmentExtractor { get; }
|
||||
public ITemplateRenderer TemplateRenderer { get; }
|
||||
public IGuidGenerator GuidGenerator { get; }
|
||||
public IServiceProvider ServiceProvider { get; }
|
||||
protected ILogManager LogManager { get; }
|
||||
private readonly IConfiguration Configuration;
|
||||
|
||||
public AttachmentGenerator(
|
||||
IAttachmentExtractor attachmentExtractor,
|
||||
ITemplateRenderer templateRenderer,
|
||||
IGuidGenerator guidGenerator,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogManager logManager,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
AttachmentExtractor = attachmentExtractor;
|
||||
TemplateRenderer = templateRenderer;
|
||||
GuidGenerator = guidGenerator;
|
||||
ServiceProvider = serviceProvider;
|
||||
LogManager = logManager;
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verilen parametreler (attachment parametreleri) ile KIM raporlari ile istenilen formatta dosya uretir
|
||||
/// </summary>
|
||||
/// <param name="attachment">Export edilecek olan attachmentlar</param>
|
||||
/// <param name="attachmentParametre">Her attachment için parametreler</param>
|
||||
/// <returns>Uretilen ekleri Key: MailEkAdi, Value: Base64 encode edilmis dosya olan dictionary olarak döner</returns>
|
||||
public async Task<Dictionary<string, string>> GenerateAsync(
|
||||
Guid kuyrukId,
|
||||
string attachment,
|
||||
string attachmentParametre,
|
||||
string jobId)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
var models = AttachmentExtractor.Extract(attachment, attachmentParametre);
|
||||
var attachmentFolder = $"{kuyrukId}-{jobId}";
|
||||
foreach (var item in models)
|
||||
{
|
||||
var file = GenerateAttachment(item, attachmentFolder);
|
||||
if (file.IsNullOrEmpty())
|
||||
{
|
||||
LogManager.LogError($"Attachment oluşturulamadı. Index: {item.Index}", "ATTACHMENT", "KUYRUK", kuyrukId, jobId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var base64 = await ConvertFileToBase64Async(file);
|
||||
var key = item.MailEkAdi;
|
||||
if (result.ContainsKey(key))
|
||||
{
|
||||
key = result.Count + "-" + item.MailEkAdi;
|
||||
}
|
||||
result.Add(key, base64);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verilan tablo modeli ile istenilen dosya tipinde (PDF, XLS, TXT) ek üretir
|
||||
/// </summary>
|
||||
|
|
@ -59,7 +108,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
if (fileGenerator == null)
|
||||
{
|
||||
LogManager.LogError($"File generator could not be created. Index: {table.Index}, FileType: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"File Generator oluşturulamadı. Index: {table.Index}, Dosya Tipi: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +124,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogManager.LogError($"File could not be generated. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"Dosya oluşturulamadı. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +132,70 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
return attachments;
|
||||
}
|
||||
|
||||
private string GenerateAttachment(AttachmentModel model, string attachmentFolder)
|
||||
{
|
||||
if (model.Filtreler.IsNullOrEmpty() || model.RaporAdi.IsNullOrWhiteSpace())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
// if (model.Filtreler.ContainsKey("QUERY") && model.Filtreler.ContainsKey("QUERYSQL"))
|
||||
// {
|
||||
// /*
|
||||
//Parametrelerden asagidaki sekilde gelen queryleri en alttaki formata donusturuyoruz
|
||||
//params
|
||||
//0 =>
|
||||
// QUERY -> qryDepo_Cikis_CekiListesi_Detayli
|
||||
// QUERYSQL -> Ksp_Depo_Cikis_CekiListesi_Detayli 252993
|
||||
// QUERY2 -> qryDepo_Cikis_CekiListesi_Sub
|
||||
// QUERY2SQL -> KSp_Depo_Cikis_CekiListesi_Sub 252993
|
||||
|
||||
//SQL olan keyvalue'lari cikar ve sadece querylerde foreach yap
|
||||
// QUERY -> qryDepo_Cikis_CekiListesi_Detayli
|
||||
// dict.add(item.Value, params[item.Key + "SQL"].Value)
|
||||
// QUERY2 -> qryDepo_Cikis_CekiListesi_Sub
|
||||
// dict.add(item.Value, params[item.Key + "SQL"].Value)
|
||||
|
||||
//Bunlari yeni dict'e ekle
|
||||
//dict
|
||||
//qryDepo_Cikis_CekiListesi_Detayli -> Ksp_Depo_Cikis_CekiListesi_Detayli 252993
|
||||
//qryDepo_Cikis_CekiListesi_Sub -> KSp_Depo_Cikis_CekiListesi_Sub 252993
|
||||
// */
|
||||
// //dict -> key (name), value (sql)
|
||||
// var queries = model.Filtreler
|
||||
// .Where(a => a.Key.StartsWith("QUERY"))
|
||||
// .Where(a => !a.Key.EndsWith("SQL"))
|
||||
// .Where(a => !a.Value.IsNullOrWhiteSpace())
|
||||
// .ToDictionary(x => x.Value, x => model.Filtreler.GetValueOrDefault(x.Key + "SQL"));
|
||||
|
||||
// return MsAccessReportManager.GenerateAccessReport(
|
||||
// model.DbAdi,
|
||||
// model.Index,
|
||||
// model.RaporAdi,
|
||||
// model.ExportDosyaTipi,
|
||||
// queries,
|
||||
// attachmentFolder);
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// return MsAccessReportManager.GenerateAccessReport(
|
||||
// model.DbAdi,
|
||||
// model.Index,
|
||||
// model.RaporAdi,
|
||||
// string.Join(" AND ", model.Filtreler.Select(a => $"{a.Key}={a.Value}")),
|
||||
// model.ExportDosyaTipi,
|
||||
// attachmentFolder);
|
||||
// }
|
||||
}
|
||||
|
||||
private async Task<string> ConvertFileToBase64Async(string filePath)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private void LogToFile(Guid id, byte[] content, string fileName)
|
||||
{
|
||||
if (!Configuration.GetValue<bool>("App:LogMailToFile"))
|
||||
|
|
@ -111,3 +224,4 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.Timing;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.TextTemplating;
|
||||
|
||||
|
|
@ -24,20 +23,17 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
public IParameterExtractor ParameterExtractor { get; }
|
||||
public ITemplateRenderer TemplateRenderer { get; }
|
||||
protected ILogManager LogManager { get; }
|
||||
protected IClock Clock { get; }
|
||||
|
||||
public MailBodyGenerator(
|
||||
IParameterExtractor parameterExtractor,
|
||||
ITemplateRenderer templateRenderer,
|
||||
IConfiguration configuration,
|
||||
ILogManager logManager,
|
||||
IClock clock)
|
||||
ILogManager logManager)
|
||||
{
|
||||
ParameterExtractor = parameterExtractor;
|
||||
TemplateRenderer = templateRenderer;
|
||||
this.configuration = configuration;
|
||||
LogManager = logManager;
|
||||
Clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -70,7 +66,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LogManager.LogError($"Template render failed: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"Template Render Başarısız: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +84,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
return;
|
||||
}
|
||||
path = Path.Combine(path, id.ToString());
|
||||
var date = Clock.Now.ToString("yyyy-MM-dd-HH-mm-ss");
|
||||
var date = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss");
|
||||
var dosyaAdi = $"mail-{id}-{date}.html ";
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
|
||||
public class AttachmentModel
|
||||
{
|
||||
public string Index { get; set; }
|
||||
public string DbAdi { get; set; } //MRP.mdb, CRM.mdb
|
||||
public string RaporAdi { get; set; }
|
||||
public string ExportDosyaTipi { get; set; } //PDF, TXT, XLS, ..
|
||||
public Dictionary<string, string> Filtreler { get; set; }
|
||||
public string MailEkAdi { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
using Sozsoft.Platform.Localization;
|
||||
|
||||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
|
||||
public class MailTemplateTableColumnModel
|
||||
{
|
||||
|
|
@ -17,10 +15,5 @@ public class MailTemplateTableColumnModel
|
|||
public string AltToplam { get; set; }
|
||||
public int Genislik { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hucre metni: <c>DataFormat</c> deseni aktif kulturde uygulanir (HTML/PDF/TXT ortak yol).
|
||||
/// XLS bu yoldan gecmez; orada desen Excel bicim kodu olarak hucreye yazilir.
|
||||
/// </summary>
|
||||
public string FormatValue(object value) => CultureFormat.Value(value, VeriFormati);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using Sozsoft.MailQueue.Domain.Entities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
using Sozsoft.MailQueue.Domain.Shared;
|
||||
using Sozsoft.Sender.Mail;
|
||||
using Volo.Abp.BackgroundWorkers;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Guids;
|
||||
using Volo.Abp.Timing;
|
||||
using Volo.Abp.Uow;
|
||||
|
||||
|
|
@ -16,105 +18,195 @@ public class MailQueueWorker : BackgroundWorkerBase
|
|||
public MailQueueWorkerOptions Options { get; set; }
|
||||
|
||||
protected ISozsoftEmailSender ErpEmailSender { get; }
|
||||
protected IRepository<BackgroundWorker_MailQueue> Repository { get; }
|
||||
protected IRepository<Entities.BackgroundWorker_MailQueue> Repository { get; }
|
||||
public IClock Clock { get; }
|
||||
public IConfiguration Configuration { get; }
|
||||
public ILogManager LogManager { get; }
|
||||
public ITableGenerator TableGenerator { get; }
|
||||
public IMailBodyGenerator MailBodyGenerator { get; }
|
||||
public IAttachmentGenerator AttachmentGenerator { get; }
|
||||
public IGuidGenerator GuidGenerator { get; }
|
||||
|
||||
public MailQueueWorker(
|
||||
ISozsoftEmailSender erpEmailSender,
|
||||
IRepository<BackgroundWorker_MailQueue> repository,
|
||||
IRepository<Entities.BackgroundWorker_MailQueue> repository,
|
||||
IClock clock,
|
||||
IConfiguration configuration,
|
||||
ILogManager logManager,
|
||||
ITableGenerator tableGenerator,
|
||||
IMailBodyGenerator mailBodyGenerator,
|
||||
IAttachmentGenerator attachmentGenerator)
|
||||
IAttachmentGenerator attachmentGenerator,
|
||||
IGuidGenerator guidGenerator
|
||||
)
|
||||
{
|
||||
ErpEmailSender = erpEmailSender;
|
||||
Repository = repository;
|
||||
Clock = clock;
|
||||
Configuration = configuration;
|
||||
LogManager = logManager;
|
||||
TableGenerator = tableGenerator;
|
||||
MailBodyGenerator = mailBodyGenerator;
|
||||
AttachmentGenerator = attachmentGenerator;
|
||||
GuidGenerator = guidGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Şablonun (TemplateId) gönderilmemiş kuyruk kayıtlarını işler: her kayıt için tablo, gövde ve
|
||||
/// ekler üretilir, mail gönderilir, sonuç kayda yazılır. Akış: tablo → gövde → ekler → gönderim.
|
||||
/// ID'si verilen Template satırının kuyruk kayıtlarını proses ederek bildirimleri yapar
|
||||
/// </summary>
|
||||
public override async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
/*
|
||||
* TemplateId'den ilgili template satırını al
|
||||
* TemplateId'den ilgili kuyruk kayıtlarını al
|
||||
* Bu kurallar inclusive'dir.
|
||||
* Yani birden fazla kural aynı kuyruk kaydı için işletilebilir.
|
||||
* BeforeSP varsa çalıştır.
|
||||
* SP görevini yaptıktan sonra devam et
|
||||
* Template:
|
||||
* Dosya Tipi: NULL, PDF, XLS, IN
|
||||
* NULL: Sadece mail body
|
||||
* PDF, XLS, TXT, XML, HTML, RTF: Bu durumda ilgili rapor hazırlanıp eklenir
|
||||
* IN: Tablo, mail body'e gömülür
|
||||
* Tablo:
|
||||
* Mail Body metni oluşturulacak
|
||||
* Kuyruk tablosu MAILPARAMETRE alanı ile
|
||||
* MAILTEMPLATE'de belirtilen HTML üretilerek
|
||||
* mail body oluşturulur
|
||||
* Tablo Alanı dolu ise;
|
||||
* Belirtilen tablodan kayıtları al ve Kuyruk'taki
|
||||
* TABLOPARAMETRE ve TABLO alanlarına göre tabloyu oluştur
|
||||
* ve Dosya Tipi'ne göre bildirime ekle.
|
||||
* Bu durumda ilgili Tabloda belirtilen css ve başlıklar kullanılmalı
|
||||
* Kuyruk - ATTACHMENT ve ATTACHMENTPARAMETRE dolu ise;
|
||||
* Bu alanlarda belirtilen raporlar Access API ile
|
||||
* oluşturulup DOSYA TIPI'ne göre bildirime eklenecek
|
||||
* Gönderimi Yap
|
||||
* KUYRUK - GATEWAYTEMPLATEID alanında belirtilen template ile
|
||||
* mail gateway'de ilgili template gönderimi yapılır
|
||||
* KIMDEN ve KIME alanları kullanılır.
|
||||
* Gateway'e gönderim emri verildikten sonra
|
||||
* GonderilmeTarihi alanı setlenir.
|
||||
* AFTERSP'de belirtilen SP'yi veritabanında çalıştır
|
||||
*
|
||||
*/
|
||||
/*
|
||||
Options:
|
||||
- Mail gidecek kişiler listesi
|
||||
- Mail subject
|
||||
- Mail lojik türü
|
||||
- Kayıt kaynağı
|
||||
- RS Filtre (id, vs)
|
||||
-
|
||||
|
||||
1- Template Id'ye göre, Kuyruk tablosunu filtrele
|
||||
2- Her kayıt için mail gönder
|
||||
*/
|
||||
using var scope = ServiceProvider.CreateScope();
|
||||
var uowManager = LazyServiceProvider.LazyGetRequiredService<IUnitOfWorkManager>();
|
||||
using var uow = uowManager.Begin();
|
||||
var jobId = $"{TemplateId}-{Clock.Now:yyyy-MM-dd-HH-mm-ss}";
|
||||
var attachmentsBasePath = Configuration.GetValue<string>("App:AttachmentsPath");
|
||||
|
||||
//var template = await RepositoryTemplate.GetAsync(a => a.Id == templateId);
|
||||
var queues = await Repository.GetListAsync(a => a.TemplateId == TemplateId && !a.SendStatus, cancellationToken: cancellationToken);
|
||||
foreach (var queue in queues)
|
||||
{
|
||||
// Her kayıt kendi UoW'sinde: bir kaydın hatası diğerlerinin gönderimini ve kaydını düşürmez.
|
||||
using var uow2 = uowManager.Begin(requiresNew: true, isTransactional: false);
|
||||
try
|
||||
{
|
||||
await ProcessQueueAsync(queue, jobId, cancellationToken);
|
||||
await uow2.CompleteAsync(cancellationToken);
|
||||
// Tablo oluştur
|
||||
var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id);
|
||||
|
||||
// Mail Body oluştur
|
||||
var mailBody = await MailBodyGenerator.GenerateAsync(
|
||||
TemplateName,
|
||||
queue.MailParameter,
|
||||
queue.Id,
|
||||
tables,
|
||||
jobId);
|
||||
|
||||
// Attachment oluştur
|
||||
// Iki attachment türü var:
|
||||
// 1- ATTACHMENT alanı dolu olanlar
|
||||
var attachments = await AttachmentGenerator.GenerateAsync(queue.Id, queue.Attachment, queue.AttachmentParameter, jobId);
|
||||
if (!queue.Attachment.IsNullOrWhiteSpace() && attachments.IsNullOrEmpty())
|
||||
{
|
||||
//Kuyruk uzerinde Attachment var fakat hicbiri olusturulamis, devam etmeyelim
|
||||
LogManager.LogWarning("Attachmentlar oluşturulamadı", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2- Tablo (IN olmayanlar-PDF,XLS,TXT)
|
||||
var attachmentsTable = await AttachmentGenerator.GenerateAsync(tables.Values.ToList(), queue.Id, jobId);
|
||||
// IN olmayan herhangi bir tablo belirtilmisse fakat bu tablo attachment olarak uretilememisse, hata vardir
|
||||
if (tables.Values.Where(a => a.DosyaTipi != "IN").Any() && attachmentsTable.IsNullOrEmpty())
|
||||
{
|
||||
//NULL ise hata olmustur, devam etmeyelim
|
||||
LogManager.LogWarning("Tablo Attachmentlar oluşturulamadı", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attachmentTable in attachmentsTable)
|
||||
{
|
||||
var key = attachmentTable.Key;
|
||||
if (attachments.ContainsKey(key))
|
||||
{
|
||||
key += "-" + GuidGenerator.Create();
|
||||
}
|
||||
attachments.Add(key, attachmentTable.Value);
|
||||
}
|
||||
|
||||
|
||||
// Gönderimi yap
|
||||
var senderKeyValue = queue.From.Split(":");
|
||||
var sender = new KeyValuePair<string, string>(
|
||||
senderKeyValue.ElementAtOrDefault(0),
|
||||
senderKeyValue.ElementAtOrDefault(1));
|
||||
var result = await ErpEmailSender.SendEmailAsync(
|
||||
queue.To.Split(";").Select(a => a.Trim()).ToArray(),
|
||||
sender,
|
||||
null,
|
||||
mailBody,
|
||||
Options.MailSubject,
|
||||
attachments,
|
||||
true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
queue.AwsMessageId = result.MessageId;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogManager.LogWarning($"Mail gönderilemedi. Gelen Mesaj: {result.ErrorMessage}", "MAILSEND", "KUYRUK", queue.Id, jobId);
|
||||
queue.AwsMessageId = result.ErrorMessage;
|
||||
}
|
||||
queue.SendStatus = result.Success;
|
||||
queue.SendTime = Clock.Now;
|
||||
await Repository.UpdateAsync(queue, cancellationToken: cancellationToken);
|
||||
|
||||
// Attachmentlari silebiliriz
|
||||
try
|
||||
{
|
||||
var attachmentsPath = Path.Combine(attachmentsBasePath, $"{queue.Id}-{jobId}");
|
||||
if (Directory.Exists(attachmentsPath))
|
||||
{
|
||||
Directory.Delete(attachmentsPath, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogManager.LogError($"Attachments klasörü silinemedi. Kuyruk Id: {queue.Id}. {ex}", "HATA", "ATTACHMENT", queue.Id, jobId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogManager.LogError($"Hata. Kuyruk Id: {queue.Id}. {ex}", "HATA", "KUYRUK", queue.Id, jobId);
|
||||
}
|
||||
await uow2.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await uow.CompleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync(BackgroundWorker_MailQueue queue, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id);
|
||||
|
||||
var mailBody = await MailBodyGenerator.GenerateAsync(
|
||||
TemplateName,
|
||||
queue.MailParameter,
|
||||
queue.Id,
|
||||
tables,
|
||||
jobId);
|
||||
|
||||
// Ekler tablo şablonlarından (IN olmayanlar: PDF, XLS, TXT) üretilir.
|
||||
var attachments = await AttachmentGenerator.GenerateAsync(tables.Values.ToList(), queue.Id, jobId);
|
||||
// IN olmayan bir tablo belirtilmiş ama hiçbiri üretilememişse hata vardır, gönderilmez.
|
||||
if (tables.Values.Any(a => a.DosyaTipi != "IN") && attachments.IsNullOrEmpty())
|
||||
{
|
||||
LogManager.LogWarning("Table attachments could not be generated", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var senderKeyValue = queue.From.Split(":");
|
||||
var sender = new KeyValuePair<string, string>(
|
||||
senderKeyValue.ElementAtOrDefault(0),
|
||||
senderKeyValue.ElementAtOrDefault(1));
|
||||
var result = await ErpEmailSender.SendEmailAsync(
|
||||
queue.To.Split(";").Select(a => a.Trim()).ToArray(),
|
||||
sender,
|
||||
null,
|
||||
mailBody,
|
||||
Options.MailSubject,
|
||||
attachments,
|
||||
true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
queue.AwsMessageId = result.MessageId;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogManager.LogWarning($"Mail could not be sent. Message: {result.ErrorMessage}", "MAILSEND", "KUYRUK", queue.Id, jobId);
|
||||
queue.AwsMessageId = result.ErrorMessage;
|
||||
}
|
||||
|
||||
queue.SendStatus = result.Success;
|
||||
queue.SendTime = Clock.Now;
|
||||
await Repository.UpdateAsync(queue, cancellationToken: cancellationToken);
|
||||
await uow.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sozsoft.MailQueue.Domain.Entities;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
|
|
@ -21,50 +20,34 @@ public class QueryRepository : IQueryRepository, ITransientDependency
|
|||
Repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mail şablonunun tablo kaynağını okur. Tablo ve kolon adları şablon kaydından gelir, parametre
|
||||
/// olarak bağlanamaz; bu yüzden beyaz listeden geçirilip ANSI tırnakla yazılır (iki sağlayıcıda da
|
||||
/// geçerli), değerler ise parametredir.
|
||||
/// </summary>
|
||||
public async Task<DataTable> QueryAsync(string tabloAdi, Dictionary<string, string> where)
|
||||
{
|
||||
if (!SqlIdentifier.IsSafe(tabloAdi))
|
||||
throw new ArgumentException($"Invalid table name: {tabloAdi}", nameof(tabloAdi));
|
||||
|
||||
var table = new DataTable();
|
||||
var ctx = await Repository.GetDbContextAsync();
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
|
||||
var cmd = ctx.Database.GetDbConnection().CreateCommand();
|
||||
var query = $"SELECT * FROM {tabloAdi} WHERE ";
|
||||
var wheres = new List<string>();
|
||||
var index = 0;
|
||||
foreach (var item in where)
|
||||
{
|
||||
if (!SqlIdentifier.IsSafe(item.Key))
|
||||
throw new ArgumentException($"Invalid column name: {item.Key}", nameof(where));
|
||||
|
||||
var parameterName = $"@p{index++}";
|
||||
wheres.Add($"{SqlIdentifier.QuoteAnsi(item.Key)} = {parameterName}");
|
||||
var parameterName = $"@p{item.Key}";
|
||||
wheres.Add($"{item.Key}={parameterName}");
|
||||
var prm = cmd.CreateParameter();
|
||||
prm.ParameterName = parameterName;
|
||||
prm.Value = item.Value;
|
||||
cmd.Parameters.Add(prm);
|
||||
}
|
||||
|
||||
cmd.CommandText = $"SELECT * FROM {SqlIdentifier.QuoteAnsi(tabloAdi)}"
|
||||
+ (wheres.Count > 0 ? " WHERE " + string.Join(" AND ", wheres) : string.Empty);
|
||||
|
||||
await connection.OpenAsync();
|
||||
query += string.Join(" AND ", wheres);
|
||||
cmd.CommandText = query;
|
||||
try
|
||||
{
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
table.Load(reader);
|
||||
cmd.Connection.Open();
|
||||
table.Load(await cmd.ExecuteReaderAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
cmd.Connection.Close();
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public class TXTFileGenerator : IFileGenerator, ITransientDependency
|
|||
{
|
||||
var sb = new StringBuilder();
|
||||
table.TabloDegerleri.ForEach(row =>
|
||||
sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => col.FormatValue(row.GetValueOrDefault(col.SutunAdi))))));
|
||||
sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => row.GetValueOrDefault(col.SutunAdi)))));
|
||||
|
||||
return await Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ table.tablo tbody tr:hover {
|
|||
{
|
||||
<tr>
|
||||
@foreach (var sutun in tablo0.TabloSutunlari){
|
||||
<td>@sutun.FormatValue(satir.GetValueOrDefault(sutun.SutunAdi))</td>
|
||||
<td>@satir.GetValueOrDefault(sutun.SutunAdi)</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
using System.Globalization;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ClosedXML.Excel;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
|
|
@ -9,8 +6,6 @@ namespace Sozsoft.MailQueue.FileGeneration;
|
|||
|
||||
public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
||||
{
|
||||
public ILogger<XLSFileGenerator> Logger { get; set; } = NullLogger<XLSFileGenerator>.Instance;
|
||||
|
||||
string sheetPassword = "Erp123";
|
||||
|
||||
public async Task<byte[]> Generate(MailTemplateTableModel table)
|
||||
|
|
@ -92,8 +87,7 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
|||
switch (dataType)
|
||||
{
|
||||
case XLDataType.Number:
|
||||
// Int32'ye cevirmek ondaligi atardi; hucre sayisal kalir, gosterimi Excel bicim kodu verir.
|
||||
cell.SetValue(Convert.ToDecimal(value, CultureInfo.InvariantCulture));
|
||||
cell.SetValue(Convert.ToInt32(value));
|
||||
break;
|
||||
case XLDataType.DateTime:
|
||||
cell.SetValue(Convert.ToDateTime(value));
|
||||
|
|
@ -172,11 +166,7 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
|||
cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Geçersiz CSS değeri hücreyi biçimsiz bırakır; dosya üretimi sürer.
|
||||
Logger.LogWarning(ex, "Cell CSS could not be applied: {Css}", string.Join(";", css.Select(a => a.Key + ":" + a.Value)));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sozsoft.Sender\Sozsoft.Sender.csproj" />
|
||||
<ProjectReference Include="..\..\src\Sozsoft.Platform.Domain.Shared\Sozsoft.Platform.Domain.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -26,22 +26,22 @@ namespace Sozsoft.MailQueue;
|
|||
typeof(AbpAuthorizationModule),
|
||||
typeof(AbpEntityFrameworkCoreModule),
|
||||
typeof(AbpTextTemplatingRazorModule),
|
||||
typeof(SozsoftSenderModule)
|
||||
typeof(ErpSenderModule)
|
||||
)]
|
||||
public class SozsoftMailQueueModule : AbpModule
|
||||
public class ErpMailQueueModule : AbpModule
|
||||
{
|
||||
public override void ConfigureServices(ServiceConfigurationContext context)
|
||||
{
|
||||
context.Services.AddMapperlyObjectMapper<SozsoftMailQueueModule>();
|
||||
context.Services.AddMapperlyObjectMapper<ErpMailQueueModule>();
|
||||
|
||||
Configure<AbpLocalizationOptions>(options =>
|
||||
{
|
||||
options.Resources.Add<SozsoftMailQueueResource>("en");
|
||||
options.Resources.Add<ErpMailQueueResource>("en");
|
||||
});
|
||||
|
||||
Configure<AbpExceptionLocalizationOptions>(options =>
|
||||
{
|
||||
options.MapCodeNamespace("MailQueue", typeof(SozsoftMailQueueResource));
|
||||
options.MapCodeNamespace("MailQueue", typeof(ErpMailQueueResource));
|
||||
});
|
||||
|
||||
context.Services.AddAbpDbContext<MailQueueDbContext>(options =>
|
||||
|
|
@ -51,7 +51,7 @@ public class SozsoftMailQueueModule : AbpModule
|
|||
|
||||
Configure<AbpRazorTemplateCSharpCompilerOptions>(options =>
|
||||
{
|
||||
options.References.Add(MetadataReference.CreateFromFile(typeof(SozsoftMailQueueModule).Assembly.Location));
|
||||
options.References.Add(MetadataReference.CreateFromFile(typeof(ErpMailQueueModule).Assembly.Location));
|
||||
});
|
||||
|
||||
Configure<AbpCompiledViewProviderOptions>(options =>
|
||||
|
|
@ -69,7 +69,7 @@ public class SozsoftMailQueueModule : AbpModule
|
|||
// FileGenerationModule
|
||||
Configure<AbpVirtualFileSystemOptions>(options =>
|
||||
{
|
||||
options.FileSets.AddEmbedded<SozsoftMailQueueModule>();
|
||||
options.FileSets.AddEmbedded<ErpMailQueueModule>();
|
||||
});
|
||||
|
||||
context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa");
|
||||
|
|
|
|||
|
|
@ -71,32 +71,6 @@ public class McpCreateTableRequestDto
|
|||
public bool FullAudited { get; set; } = true;
|
||||
|
||||
public bool MultiTenant { get; set; } = true;
|
||||
|
||||
public List<McpTableIndexDto> Indexes { get; set; } = [];
|
||||
|
||||
public List<McpTableForeignKeyDto> ForeignKeys { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Index ya da unique kisit; ad sunucuda <c>IX_/UQ_{Tablo}_{Kolonlar}</c> olarak uretilir.</summary>
|
||||
public class McpTableIndexDto
|
||||
{
|
||||
public List<string> Columns { get; set; } = [];
|
||||
|
||||
public bool Unique { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>FK; ad <c>FK_{Tablo}_{Kolon}</c>.</summary>
|
||||
public class McpTableForeignKeyDto
|
||||
{
|
||||
public string Column { get; set; } = string.Empty;
|
||||
|
||||
public string ReferencedTable { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Bos ise <c>Id</c>.</summary>
|
||||
public string? ReferencedColumn { get; set; }
|
||||
|
||||
/// <summary><c>NoAction</c> (varsayilan), <c>Cascade</c> ya da <c>SetNull</c>.</summary>
|
||||
public string? OnDelete { get; set; }
|
||||
}
|
||||
|
||||
public class McpTableColumnDto
|
||||
|
|
@ -125,30 +99,6 @@ public class McpCreateTableResultDto
|
|||
public string SeedFilePath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>View, procedure ya da function scriptinin iki saglayici icin yazildigi seed dosyalari.</summary>
|
||||
public class McpSqlObjectResultDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary><c>VIEW</c>, <c>PROCEDURE</c> ya da <c>FUNCTION</c>.</summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Seed kokune gore goreli yollar: <c>sql/object/{Ad}.sql</c>, <c>postgres/object/{Ad}.sql</c>.</summary>
|
||||
public List<string> SeedFilePaths { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Yazilan <c>crud/{Entity}.json</c> dosyasi ve icindeki endpoint'ler.</summary>
|
||||
public class McpCrudEndpointsResultDto
|
||||
{
|
||||
public string EntityName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Seed kokune gore goreli yol.</summary>
|
||||
public string SeedFilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary><c>{Method} {Path}</c>; pasif baslayanlar isaretlidir.</summary>
|
||||
public List<string> Endpoints { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy edilmis bir wizard'in salt-okur dogrulamasi. <see cref="Passed"/> yalnizca
|
||||
/// <see cref="McpVerifyCheckDto.Blocking"/> bir kontrol kaldiginda false olur; engellemeyen
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||
|
||||
namespace Sozsoft.Mcp.Dtos;
|
||||
|
||||
/// <summary>Rapor kategorisi (<c>Adm.Reports.Categories</c>).</summary>
|
||||
/// <summary>Rapor kategorisi (<c>App.Reports.Categories</c>).</summary>
|
||||
public class McpReportCategoryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
|
@ -106,6 +106,6 @@ public class McpReportTemplateSaveRequestDto
|
|||
/// </summary>
|
||||
public string? ListFormCode { get; set; }
|
||||
|
||||
/// <summary>Satir butonunun dil anahtari; verilmezse <c>Sas.Platform.View</c>.</summary>
|
||||
/// <summary>Satir butonunun dil anahtari; verilmezse <c>App.Platform.View</c>.</summary>
|
||||
public string? ButtonText { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,7 @@ public class McpListSummaryDto
|
|||
/// <summary>Tablo adi ya da SELECT metni — listenin gercek SQL kaynagi.</summary>
|
||||
public string? SqlSourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>table</c>, <c>selectSql</c> ya da <c>unknown</c>; gelistiricide ayrica <c>pending</c> (wizard dosyasi
|
||||
/// var, DB Migrate bekliyor) ve <c>pendingTable</c> (yalnizca seed scripti olan tablo). Kolon adlari
|
||||
/// dogrudur; query_list migrate'ten once calismaz.
|
||||
/// </summary>
|
||||
/// <summary><c>table</c>, <c>selectSql</c> ya da <c>unknown</c>.</summary>
|
||||
public string? SourceKind { get; set; }
|
||||
|
||||
public string? KeyFieldName { get; set; }
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ namespace Sozsoft.Mcp;
|
|||
/// </summary>
|
||||
public interface IMcpDataAppService : IApplicationService
|
||||
{
|
||||
Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input);
|
||||
Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input);
|
||||
|
||||
Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input);
|
||||
Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input);
|
||||
|
||||
Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input);
|
||||
Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Platform.DeveloperKit;
|
||||
|
|
@ -14,68 +14,61 @@ namespace Sozsoft.Mcp;
|
|||
/// </summary>
|
||||
public interface IMcpDevelopAppService : IApplicationService
|
||||
{
|
||||
Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section);
|
||||
Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section);
|
||||
|
||||
Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input);
|
||||
Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input);
|
||||
|
||||
/// <summary>View/procedure/function scriptini iki saglayicinin seed klasorune yazar; veritabaninda calistirmaz.</summary>
|
||||
Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript);
|
||||
Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search);
|
||||
|
||||
/// <summary>Tablo ya da view icin CRUD endpoint'lerini <c>crud/{Entity}.json</c> seed dosyasina yazar; kaydi DB Migrate ekler.</summary>
|
||||
Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType);
|
||||
|
||||
Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search);
|
||||
|
||||
Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName);
|
||||
Task<McpWizardFileDto> GetWizardAsync(string fileName);
|
||||
|
||||
/// <summary>Deploy edilen wizard'in ozeti.</summary>
|
||||
Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input);
|
||||
Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input);
|
||||
|
||||
/// <summary>Wizard'i siler; <paramref name="dropTables"/> acikken ekranin secilebilir tablosunu da dusurur.</summary>
|
||||
Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables);
|
||||
Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables);
|
||||
|
||||
/// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary>
|
||||
Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName);
|
||||
Task<McpVerifyResultDto> VerifyWizardAsync(string fileName);
|
||||
|
||||
/// <summary>Id ya da basliga gore bulunan talebi, yalnizca Ready to Start durumundaysa In Progress'e alir.</summary>
|
||||
Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request);
|
||||
Task<McpDevRequestDto> StartDevRequestAsync(string request);
|
||||
|
||||
/// <summary>Talebi durumuna dokunmadan okur; yarim kalan bir talebe donmek icindir.</summary>
|
||||
Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request);
|
||||
Task<McpDevRequestDto> GetDevRequestAsync(string request);
|
||||
|
||||
/// <summary>
|
||||
/// Talebin analizini yazar (var olani degistirir); analiz kosulsuz uretime hazirsa talebi
|
||||
/// Ready to Start'a, degilse Analysis durumuna alir. Yalnizca Backlog ya da Analysis durumundaki
|
||||
/// talep analiz edilir.
|
||||
/// Talebin analizini yazar (var olani degistirir) ve talebi Analysis durumuna alir; yalnizca
|
||||
/// Backlog ya da Analysis durumundaki talep analiz edilir.
|
||||
/// </summary>
|
||||
Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady);
|
||||
Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html);
|
||||
|
||||
/// <summary>
|
||||
/// Analizin sonuna HTML ekler. Aciklama talebin yazildigi haliyle kalir; uretim notlari
|
||||
/// analize yazilir.
|
||||
/// </summary>
|
||||
Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html);
|
||||
Task AppendDevRequestNoteAsync(string request, string html);
|
||||
|
||||
/// <summary>
|
||||
/// Talebin SubTasks listesine tamamlanmis satirlar ekler; atanan kisi cagiran kullanici,
|
||||
/// bitis tarihi bugundur.
|
||||
/// </summary>
|
||||
Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles);
|
||||
Task AddDevRequestSubTasksAsync(string request, List<string> titles);
|
||||
|
||||
/// <summary>Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur.</summary>
|
||||
Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name);
|
||||
Task<ModuleDocDto> GetModuleDocAsync(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden
|
||||
/// uretilir.
|
||||
/// </summary>
|
||||
Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content);
|
||||
Task<ModuleDocDto> SaveModuleDocAsync(string name, string content);
|
||||
|
||||
Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search);
|
||||
Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search);
|
||||
|
||||
Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name);
|
||||
Task<CustomComponentDto?> GetComponentAsync(string name);
|
||||
|
||||
Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input);
|
||||
Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input);
|
||||
|
||||
Task<McpProblem?> DeleteComponentAsync(string name);
|
||||
Task DeleteComponentAsync(string name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,17 @@ public interface IMcpQueryAppService : IApplicationService
|
|||
/// okuma yetkisi, kiraci/sube filtreleri ve kullanici ozellestirmeleri uygulamadaki gibi
|
||||
/// gecerlidir. Normal kullanicinin veri okuma yolu budur.
|
||||
/// </summary>
|
||||
Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input);
|
||||
Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input);
|
||||
|
||||
/// <summary>
|
||||
/// Tek bir SELECT ifadesi kosturur. Coklu ifade, DDL ve DML reddedilir; satir sayisi
|
||||
/// sunucu kaydindaki tavana kirpilir.
|
||||
/// </summary>
|
||||
Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input);
|
||||
Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input);
|
||||
|
||||
/// <summary>Veri kaynagindaki view/procedure/function nesneleri ve Custom Endpoint'ler.</summary>
|
||||
Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null);
|
||||
Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null);
|
||||
|
||||
/// <summary>Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir.</summary>
|
||||
Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input);
|
||||
Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Rapor kategorisi ve rapor sablonu uretimi. Yazma <c>Adm.Reports.Categories</c> ve
|
||||
/// <c>Adm.Reports.ReportTemplates</c> ekranlarinin C/U/D sozlesmesinden gecer; ekran yetkisi,
|
||||
/// Rapor kategorisi ve rapor sablonu uretimi. Yazma <c>App.Reports.Categories</c> ve
|
||||
/// <c>App.Reports.ReportTemplates</c> ekranlarinin C/U/D sozlesmesinden gecer; ekran yetkisi,
|
||||
/// kiraci filtresi ve seed senkronizasyonu oradaki yolla ayni sekilde uygulanir.
|
||||
/// </summary>
|
||||
public interface IMcpReportAppService : IApplicationService
|
||||
{
|
||||
Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search);
|
||||
Task<McpReportCatalogDto> GetReportTemplatesAsync(string? search);
|
||||
|
||||
/// <summary>Sablonu Id'si ya da adiyla, HTML icerigi ve yer tutuculariyla doner.</summary>
|
||||
Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template);
|
||||
Task<McpReportTemplateDto> GetReportTemplateAsync(string template);
|
||||
|
||||
/// <summary>Kategoriyi ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
|
||||
Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input);
|
||||
Task<McpReportCategoryDto> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input);
|
||||
|
||||
/// <summary>Sablonu ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
|
||||
Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input);
|
||||
Task<McpReportTemplateDto> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input);
|
||||
|
||||
Task<McpProblem?> DeleteReportTemplateAsync(string template);
|
||||
Task DeleteReportTemplateAsync(string template);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ public interface IMcpSchemaAppService : IApplicationService
|
|||
|
||||
/// <param name="search">Bos birakilirsa tum listeler doner.</param>
|
||||
/// <param name="maxResults">Donecek en fazla liste sayisi.</param>
|
||||
Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200);
|
||||
Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200);
|
||||
|
||||
/// <summary>Tek bir listenin sutunlariyla birlikte tanimi; bulunamazsa null.</summary>
|
||||
Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode);
|
||||
Task<McpListDetailDto?> GetListAsync(string listFormCode);
|
||||
|
||||
/// <summary>
|
||||
/// Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama.
|
||||
/// </summary>
|
||||
Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50);
|
||||
Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
|
@ -45,49 +46,40 @@ public abstract class McpAppService : ApplicationService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kapali bir araca yapilan cagriyi reddeder: sunucu kaydi yoksa, arac sunucu kaydinda kapaliysa
|
||||
/// ya da kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina
|
||||
/// Kaydi zorunlu kilan islemler icin. Kayit yoksa istemcinin nedenini gorebilecegi bir
|
||||
/// hata atar.
|
||||
/// </summary>
|
||||
protected async Task<McpServerConfigDto> RequireServerConfigAsync()
|
||||
=> await ResolveServerConfigAsync()
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.NoServerRecord]);
|
||||
|
||||
/// <summary>
|
||||
/// Kapali bir araca yapilan cagriyi reddeder: arac sunucu kaydinda kapaliysa ya da
|
||||
/// kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina
|
||||
/// yeterli degildir — istemci listede olmayan bir araci da cagirabilir.
|
||||
/// </summary>
|
||||
protected async Task<McpOutcome<McpServerConfigDto>> EnsureToolAllowedAsync(string toolName)
|
||||
protected async Task<McpServerConfigDto> EnsureToolAllowedAsync(string toolName)
|
||||
{
|
||||
var config = await ResolveServerConfigAsync();
|
||||
if (config is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.NoServerRecord);
|
||||
}
|
||||
var config = await RequireServerConfigAsync();
|
||||
|
||||
if (CurrentTenant.IsAvailable && !McpToolNames.TenantTools.Contains(toolName, StringComparer.Ordinal))
|
||||
{
|
||||
return Problem(McpErrorCodes.TenantToolDisabled, toolName);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.TenantToolDisabled, toolName]);
|
||||
}
|
||||
|
||||
if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName))
|
||||
{
|
||||
return Problem(McpErrorCodes.ToolDisabled, toolName, config.Name);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ToolDisabled, toolName, config.Name]);
|
||||
}
|
||||
|
||||
if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission) &&
|
||||
await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission))
|
||||
{
|
||||
return denied;
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yetkiyi adiyla ister; eksikse istemcinin rol ekraninda arayabilecegi ad ve kodla reddeder.
|
||||
/// <c>AuthorizationService.CheckAsync</c> istisna atar, bu yuzden MCP'de kullanilmaz.
|
||||
/// </summary>
|
||||
protected async Task<McpProblem?> CheckPermissionAsync(string toolName, string permission)
|
||||
=> await AuthorizationService.IsGrantedAsync(permission)
|
||||
? null
|
||||
: Problem(McpErrorCodes.PermissionDenied, toolName, McpPermissionText.Describe(L, permission));
|
||||
|
||||
protected McpProblem Problem(string name, params object[] arguments)
|
||||
=> McpProblem.From(L, name, arguments);
|
||||
|
||||
/// <summary>Istenen satir sayisini sunucu kaydindaki tavana kirpar.</summary>
|
||||
protected static int ResolveMaxRows(McpServerConfigDto config, int requested)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,29 +1,12 @@
|
|||
using Sozsoft.Platform.Enums;
|
||||
|
||||
namespace Sozsoft.Platform.Data;
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Veritabanındaki kayıtlı nesneleri (görünüm, yordam, fonksiyon) listeleyen katalog sorguları.
|
||||
/// Parametre almazlar; sabit metin oldukları için string birleştirme riski yoktur. SQL Query Manager
|
||||
/// ve MCP aynı listeyi gösterdiği için sorgu tek yerde durur; tür ayrımı C# tarafında
|
||||
/// <see cref="ObjectKinds"/> ile yapılır.
|
||||
/// Kayitli nesneleri listelemek icin kullanilan katalog sorgulari. Parametre almazlar;
|
||||
/// sabit metin oldugu icin string birlestirme riski yoktur.
|
||||
/// </summary>
|
||||
public static class SqlCatalogQueries
|
||||
internal static class McpCatalogQueries
|
||||
{
|
||||
/// <summary><c>ObjectKind</c> kolonunun aldığı değerler.</summary>
|
||||
public static class ObjectKinds
|
||||
{
|
||||
public const string View = "VIEW";
|
||||
public const string Procedure = "PROCEDURE";
|
||||
public const string TableFunction = "TABLE_FUNCTION";
|
||||
public const string ScalarFunction = "SCALAR_FUNCTION";
|
||||
}
|
||||
|
||||
/// <summary><c>SchemaName</c>, <c>ObjectName</c>, <c>ObjectKind</c> kolonlarını şema + ad sırasıyla döner.</summary>
|
||||
public static string NativeObjects(DataSourceTypeEnum provider)
|
||||
=> provider == DataSourceTypeEnum.Postgresql ? PostgresNativeObjects : SqlServerNativeObjects;
|
||||
|
||||
private const string SqlServerNativeObjects = """
|
||||
public const string SqlServer = """
|
||||
SELECT
|
||||
SCHEMA_NAME(o.schema_id) AS SchemaName,
|
||||
o.name AS ObjectName,
|
||||
|
|
@ -40,7 +23,7 @@ public static class SqlCatalogQueries
|
|||
ORDER BY SchemaName, ObjectName
|
||||
""";
|
||||
|
||||
private const string PostgresNativeObjects = """
|
||||
public const string Postgres = """
|
||||
SELECT n.nspname AS "SchemaName",
|
||||
c.relname AS "ObjectName",
|
||||
'VIEW' AS "ObjectKind"
|
||||
|
|
@ -31,44 +31,28 @@ public class McpDataAppService(
|
|||
private const string DevRequestStatusField = "Status";
|
||||
private const string DevRequestTestingStatus = "Testing";
|
||||
|
||||
public async Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var request = await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true);
|
||||
var key = await listFormDataAppService.PostInsertAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode);
|
||||
|
||||
return new McpWriteResultDto { Key = ((object?)key)?.ToString() };
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckModuleDocsBeforeTestingAsync(input) is { } incomplete)
|
||||
{
|
||||
return incomplete;
|
||||
}
|
||||
|
||||
var request = await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true);
|
||||
await EnsureModuleDocsBeforeTestingAsync(input);
|
||||
var affected = await listFormDataAppService.PostUpdateAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.UpdateRow, input.ListFormCode, string.Join(", ", input.Values.Keys));
|
||||
|
||||
return new McpWriteResultDto { AffectedRows = affected };
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var request = await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false);
|
||||
var affected = await listFormDataAppService.PostDeleteAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode);
|
||||
|
||||
|
|
@ -76,22 +60,22 @@ public class McpDataAppService(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumani bolumleri
|
||||
/// tamam olmalidir (lowcode §0.13): bolum var ve doldurulmamis (<c>module-doc:todo</c>) alt bolum yok.
|
||||
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumanlari tamam
|
||||
/// olmalidir (lowcode §0.13): dosya var ve doldurulmamis (<c>module-doc:todo</c>) bolum yok.
|
||||
/// Talebi Testing'e alan tek yol <c>update_row</c> oldugu icin kural burada uygulanir. Wizard
|
||||
/// okuma yetkisi olmayan kullanici wizard uretemeyecegi icin kontrol ona uygulanmaz.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> CheckModuleDocsBeforeTestingAsync(McpRowRequestDto input)
|
||||
private async Task EnsureModuleDocsBeforeTestingAsync(McpRowRequestDto input)
|
||||
{
|
||||
if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return;
|
||||
|
||||
var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
|
||||
if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return;
|
||||
|
||||
if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
|
||||
return null;
|
||||
return;
|
||||
|
||||
foreach (var key in input.Keys ?? [])
|
||||
{
|
||||
|
|
@ -104,17 +88,15 @@ public class McpDataAppService(
|
|||
|
||||
var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})"));
|
||||
|
||||
return Problem(McpErrorCodes.DevRequestModuleDocsIncomplete, detail);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestModuleDocsIncomplete, detail]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Istegi ekranin bekledigi bicime cevirir. <c>Data</c> JSON metni olarak verilir; ayni
|
||||
/// bicim ekranin kopyalama yolunda da kullanilir.
|
||||
/// </summary>
|
||||
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
|
||||
private async Task<DataRequestDto> BuildRequestAsync(
|
||||
McpRowRequestDto input,
|
||||
string toolName,
|
||||
AuthorizationTypeEnum operation,
|
||||
|
|
@ -123,32 +105,29 @@ public class McpDataAppService(
|
|||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(toolName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.ListFormCode))
|
||||
{
|
||||
return Problem(McpErrorCodes.ListFormCodeRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ListFormCodeRequired]);
|
||||
}
|
||||
|
||||
// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
|
||||
// istemci hangi yetkinin eksik oldugunu gorsun.
|
||||
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, operation);
|
||||
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
if (permission is not null)
|
||||
{
|
||||
return denied;
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
}
|
||||
|
||||
if (requireValues && (input.Values is null || input.Values.Count == 0))
|
||||
{
|
||||
return Problem(McpErrorCodes.ValuesRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ValuesRequired]);
|
||||
}
|
||||
|
||||
if (requireKeys && (input.Keys is null || input.Keys.Count == 0))
|
||||
{
|
||||
return Problem(McpErrorCodes.KeysRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.KeysRequired]);
|
||||
}
|
||||
|
||||
return new DataRequestDto
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
|
@ -35,7 +36,6 @@ public class McpDevelopAppService(
|
|||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpDevGuide devGuide,
|
||||
IMcpTableCreator tableCreator,
|
||||
IMcpSqlObjectWriter sqlObjectWriter,
|
||||
IListFormWizardAppService wizardAppService,
|
||||
ICustomComponentAppService componentAppService,
|
||||
IListFormSelectAppService listFormSelectAppService,
|
||||
|
|
@ -79,19 +79,16 @@ public class McpDevelopAppService(
|
|||
public const string SeedClosure = "seed_closure";
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section)
|
||||
public async Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetDevGuide) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetDevGuide);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(section))
|
||||
{
|
||||
var headings = await devGuide.GetHeadingsAsync();
|
||||
if (headings.Count == 0)
|
||||
{
|
||||
return Problem(McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]);
|
||||
}
|
||||
|
||||
return new McpDevGuideDto
|
||||
|
|
@ -157,31 +154,18 @@ public class McpDevelopAppService(
|
|||
/// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca
|
||||
/// MCP kapisidir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input)
|
||||
public async Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.CreateTable) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.CreateTable);
|
||||
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default);
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.CreateTable, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
var outcome = await tableCreator.CreateAsync(new McpTableDefinition(
|
||||
var created = await tableCreator.CreateAsync(new McpTableDefinition(
|
||||
input.TableName,
|
||||
[.. (input.Columns ?? []).Select(column => new McpTableColumn(
|
||||
column.Name, column.Type, column.MaxLength, column.IsNullable, column.DefaultValue))],
|
||||
input.FullAudited,
|
||||
input.MultiTenant,
|
||||
[.. (input.Indexes ?? []).Select(index => new McpTableIndex(index.Columns ?? [], index.Unique))],
|
||||
[.. (input.ForeignKeys ?? []).Select(fk => new McpTableForeignKey(fk.Column, fk.ReferencedTable, fk.ReferencedColumn, fk.OnDelete))]));
|
||||
if (!outcome.TryGetValue(out var created, out var invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
input.MultiTenant));
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName);
|
||||
|
||||
|
|
@ -193,84 +177,9 @@ public class McpDevelopAppService(
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function
|
||||
/// kaydinin karsiligidir. Nesneyi DB Migrate kurar.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript)
|
||||
public async Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.SaveSqlObject, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!sqlObjectWriter.Save(sqlServerScript, postgreSqlScript).TryGetValue(out var saved, out var invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveSqlObject, saved.Name);
|
||||
|
||||
return new McpSqlObjectResultDto
|
||||
{
|
||||
Name = saved.Name,
|
||||
Kind = saved.Kind,
|
||||
SeedFilePaths = [saved.SqlServerPath, saved.PostgreSqlPath]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL Query Manager'daki CRUD endpoint diyaloguyla ayni endpoint kumesini
|
||||
/// (<see cref="CrudEndpointSeedFileDto.Generate"/>) yalnizca <c>crud/{Entity}.json</c> seed dosyasina
|
||||
/// yazar; kayitlari DB Migrate ekler (<c>CrudEndpointSeedApplier</c>). Yetki diyalogunkiyle aynidir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.GenerateCrudEndpoints, PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
// Tasarimcidaki gibi sema oneki atilir: endpoint yolu yalnizca nesne adini tasir.
|
||||
var entity = entityName?.Split('.')[^1].Trim('[', ']', '"', ' ') ?? string.Empty;
|
||||
if (CrudEndpointSeedFileDto.SafeFileName(entity) is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty);
|
||||
}
|
||||
|
||||
// Model listeyi JSON dizisi olarak da gonderebilir (["GetList","Create"]); metne cevrilen dizi
|
||||
// virgullu listeye indirilir.
|
||||
var operations = operationTypes is null
|
||||
? null
|
||||
: string.Join(',', operationTypes.Split(',').Select(item => item.Trim('[', ']', '"', ' ')));
|
||||
var seedFile = CrudEndpointSeedFileDto.Generate(entity, operations, objectType, Clock.Now.ToUniversalTime());
|
||||
seedFile.Write(configuration);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.GenerateCrudEndpoints, entity);
|
||||
|
||||
return new McpCrudEndpointsResultDto
|
||||
{
|
||||
EntityName = entity,
|
||||
SeedFilePath = $"{SeedPathResolver.CrudFolder}/{CrudEndpointSeedFileDto.SafeFileName(entity)}.json",
|
||||
Endpoints = [.. seedFile.Endpoints.Select(e => $"{e.Method} {e.Path}{(e.IsActive ? string.Empty : " [inactive]")}")]
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListWizards) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListWizards);
|
||||
|
||||
var files = await wizardAppService.GetFiles();
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
|
|
@ -278,17 +187,14 @@ public class McpDevelopAppService(
|
|||
return files;
|
||||
}
|
||||
|
||||
return files.Where(file =>
|
||||
return [.. files.Where(file =>
|
||||
Contains(file.FileName, search) || Contains(file.WizardName, search) ||
|
||||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search)).ToList();
|
||||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))];
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName)
|
||||
public async Task<McpWizardFileDto> GetWizardAsync(string fileName)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetWizard);
|
||||
|
||||
var seed = await wizardAppService.GetFile(fileName);
|
||||
|
||||
|
|
@ -299,24 +205,14 @@ public class McpDevelopAppService(
|
|||
return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() };
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input)
|
||||
public async Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeployWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (!ParseWizard(input.Wizard).TryGetValue(out var wizard, out var invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeployWizard);
|
||||
|
||||
var wizard = ParseWizard(input.Wizard);
|
||||
wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim();
|
||||
if (CheckDetailedDescription(wizard) is { } shortDescription)
|
||||
{
|
||||
return shortDescription;
|
||||
}
|
||||
EnsureDetailedDescription(wizard);
|
||||
|
||||
// Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy
|
||||
// bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir),
|
||||
|
|
@ -324,16 +220,6 @@ public class McpDevelopAppService(
|
|||
var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard;
|
||||
wizard.MenuOrder = previous?.MenuOrder ?? 0;
|
||||
|
||||
// deploy_wizard dosyayi tamamen degistirir. Duzenlemede WizardCode yazilmamissa kod sessizce
|
||||
// Sas.Wizard.{Ad}'a duser: girdi kismidir ve deploy ekranin menusunu, yetkilerini ve alanlarini
|
||||
// bosaltirdi. Kodu bilerek degistiren girdi WizardCode'u acikca yazar.
|
||||
if (previous is not null && string.IsNullOrWhiteSpace(wizard.ListFormCode) &&
|
||||
!string.IsNullOrWhiteSpace(previous.ListFormCode) &&
|
||||
!string.Equals(previous.ListFormCode.Trim(), wizard.ResolveListFormCode(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Problem(McpErrorCodes.WizardCodeMissing, wizard.EditFileName!, previous.ListFormCode.Trim());
|
||||
}
|
||||
|
||||
// Alt form sekmesi ana kayit her degistiginde yeniden sorgulanmaz; MCP yeni baglanti
|
||||
// acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi
|
||||
// deger korunur.
|
||||
|
|
@ -353,10 +239,7 @@ public class McpDevelopAppService(
|
|||
editGroups[0].Caption = string.Empty;
|
||||
|
||||
// Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz.
|
||||
if (!(await ParseSeedRowsAsync(input.SeedRows, wizard)).TryGetValue(out var seedRows, out invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
var seedRows = ParseSeedRows(input.SeedRows, wizard);
|
||||
|
||||
// Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin
|
||||
// butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir.
|
||||
|
|
@ -367,8 +250,8 @@ public class McpDevelopAppService(
|
|||
var files = await wizardAppService.GetFiles();
|
||||
var file = files.FirstOrDefault(file => string.Equals(file.WizardName, wizard.WizardName?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (file != null && seedRows.Count > 0 && await WriteSeedRowsAsync(file.FileName, seedRows) is { } rowsRejected)
|
||||
return rowsRejected;
|
||||
if (file != null && seedRows.Count > 0)
|
||||
await WriteSeedRowsAsync(file.FileName, seedRows);
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count);
|
||||
|
||||
|
|
@ -381,32 +264,31 @@ public class McpDevelopAppService(
|
|||
/// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi
|
||||
/// icin MCP deploy'u kisa aciklamayi reddeder.
|
||||
/// </summary>
|
||||
private McpProblem? CheckDetailedDescription(ListFormWizardDto wizard)
|
||||
private void EnsureDetailedDescription(ListFormWizardDto wizard)
|
||||
{
|
||||
if (wizard.ComponentKind == WizardComponentKindEnum.Custom)
|
||||
return null;
|
||||
return;
|
||||
|
||||
static bool IsDetailed(string? text) =>
|
||||
!string.IsNullOrWhiteSpace(text) &&
|
||||
text.Trim().Length >= MinDescriptionLength &&
|
||||
text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines;
|
||||
|
||||
return IsDetailed(wizard.DescTr) && IsDetailed(wizard.DescEn)
|
||||
? null
|
||||
: Problem(McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength);
|
||||
if (!IsDetailed(wizard.DescTr) || !IsDetailed(wizard.DescEn))
|
||||
throw new UserFriendlyException(L[McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik
|
||||
/// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler.
|
||||
/// </summary>
|
||||
private async Task<McpOutcome<List<Dictionary<string, JsonElement>>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard)
|
||||
private List<Dictionary<string, JsonElement>> ParseSeedRows(string? json, ListFormWizardDto wizard)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<Dictionary<string, JsonElement>>();
|
||||
return [];
|
||||
|
||||
if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData)
|
||||
return Problem(McpErrorCodes.SeedRowsNotAllowed);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
|
||||
|
||||
List<Dictionary<string, JsonElement>>? rows;
|
||||
try
|
||||
|
|
@ -419,7 +301,7 @@ public class McpDevelopAppService(
|
|||
}
|
||||
|
||||
if (rows is null || rows.Any(row => row is null))
|
||||
return Problem(McpErrorCodes.SeedRowsInvalid);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]);
|
||||
|
||||
var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName)
|
||||
.Append(wizard.KeyFieldName)
|
||||
|
|
@ -428,74 +310,33 @@ public class McpDevelopAppService(
|
|||
|
||||
var unknown = rows.SelectMany(row => row.Keys).Where(key => !fields.Contains(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (unknown.Count == 0)
|
||||
return rows;
|
||||
if (unknown.Count > 0)
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), string.Join(", ", fields)]);
|
||||
|
||||
var screenFields = string.Join(", ", fields);
|
||||
var owners = await FindSameTableScreensAsync(wizard, unknown);
|
||||
if (owners.Count == 0)
|
||||
return Problem(McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), screenFields);
|
||||
|
||||
// Ayni tablonun baska ekraninda (cogunlukla alt form sekmesi) alan olan kolon migrate'te
|
||||
// bu ekranin veri dosyasindan yazilmaz; model kolonun nerede durdugunu gorurse alani ekrana
|
||||
// ekleyip ya da satirdan cikarip tekrar dener.
|
||||
var placement = string.Join("; ", owners.Select(owner => $"{owner.Key}: {string.Join(", ", owner.Value)}"));
|
||||
return Problem(McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bilinmeyen kolonlari ayni tabloyu okuyan diger wizard ekranlarinin alanlarinda arar; sonuc
|
||||
/// ekran kodundan o ekranda bulunan kolonlara eslemedir. Ekran listesi wizard dosyalarindan
|
||||
/// okunur, migrate bekleyen ekranlar da dahildir.
|
||||
/// Demo satirlarini ekranin veri dosyasina (<c>ListForm.SeedFilePath</c>) yazar; veritabanina
|
||||
/// dokunmaz. Migrate ekrani kurduktan sonra <c>ListFormSeedDataApplier</c> satirlari ekler ve
|
||||
/// kiraci/audit alanlarini doldurur. Guid anahtar burada uretilir: dosya her uygulandiginda ayni
|
||||
/// kayda karsilik gelir. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, List<string>>> FindSameTableScreensAsync(ListFormWizardDto wizard, List<string> columns)
|
||||
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
|
||||
{
|
||||
var owners = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var table = wizard.SelectCommand?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(table))
|
||||
return owners;
|
||||
var listForm = (await wizardAppService.GetFile(fileName)).ListForm;
|
||||
var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/');
|
||||
if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath))
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
|
||||
|
||||
var wizardName = wizard.WizardName?.Trim();
|
||||
foreach (var file in await wizardAppService.GetFiles())
|
||||
{
|
||||
if (file.ComponentKind == WizardComponentKindEnum.Custom ||
|
||||
string.Equals(file.WizardName, wizardName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(file.FileName, wizard.EditFileName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var path = SeedPathResolver.GetScopePath(configuration, CurrentTenant.Id, relativePath.Split('/'));
|
||||
var seedFile = File.Exists(path)
|
||||
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions)
|
||||
: null;
|
||||
seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName };
|
||||
|
||||
var seed = await wizardAppService.GetFile(file.FileName);
|
||||
if (!string.Equals(seed.Wizard?.SelectCommand?.Trim(), table, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var fieldNames = (seed.Wizard?.Groups ?? [])
|
||||
.SelectMany(group => group.Items ?? [])
|
||||
.Select(item => item.FieldName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var found = columns.Where(fieldNames.Contains).ToList();
|
||||
if (found.Count > 0)
|
||||
owners[seed.Wizard?.ResolveListFormCode() ?? file.ListFormCode ?? file.WizardName] = found;
|
||||
}
|
||||
|
||||
return owners;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demo satirlarini ekranin veri seed hedefine (<c>Data.SeedFilePath</c>; tanima gore tek
|
||||
/// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra
|
||||
/// <c>ListFormSeedDataApplier</c> satirlari ekler ve kiraci/audit alanlarini doldurur. Guid anahtar
|
||||
/// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir
|
||||
/// varsa (tekrar deploy) yeniden eklenmez.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
|
||||
{
|
||||
var wizard = (await wizardAppService.GetFile(fileName)).Wizard;
|
||||
var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), wizard?.SeedFilePath);
|
||||
if (wizard == null || target == null)
|
||||
return Problem(McpErrorCodes.SeedRowsNotAllowed);
|
||||
|
||||
var keyFieldName = wizard.KeyFieldName;
|
||||
var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
|
||||
var added = new List<(object Key, Dictionary<string, object> Row)>();
|
||||
var keyFieldName = seedFile.KeyFieldName;
|
||||
var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
|
|
@ -503,16 +344,14 @@ public class McpDevelopAppService(
|
|||
if (!existing.Add(RowContent(values, keyFieldName)))
|
||||
continue;
|
||||
|
||||
if (wizard.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
|
||||
if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
|
||||
values[keyFieldName] = GuidGenerator.Create();
|
||||
|
||||
added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values));
|
||||
seedFile.Rows.Add(values);
|
||||
}
|
||||
|
||||
// Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz.
|
||||
return added.Count > 0 && (await target.UpsertRowsAsync(wizard.ResolveListFormCode(), keyFieldName, added)).Count > 0
|
||||
? Problem(McpErrorCodes.SeedRowsInvalid)
|
||||
: null;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile));
|
||||
}
|
||||
|
||||
/// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary>
|
||||
|
|
@ -526,12 +365,9 @@ public class McpDevelopAppService(
|
|||
/// Ekranin tablosu ancak <paramref name="dropTables"/> acikken ve Wizard Manager'daki onayla ayni
|
||||
/// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables)
|
||||
public async Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeleteWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteWizard);
|
||||
|
||||
var input = new WizardDeleteInput { FileName = fileName };
|
||||
if (dropTables)
|
||||
|
|
@ -553,12 +389,9 @@ public class McpDevelopAppService(
|
|||
/// iz birakirdi. Seed kapanisi engellemez: ekran bu ortamda calisir, eksik dosya yalnizca
|
||||
/// migrate ya da baska bir ortamda ortaya cikar.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName)
|
||||
public async Task<McpVerifyResultDto> VerifyWizardAsync(string fileName)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.VerifyWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.VerifyWizard);
|
||||
|
||||
var result = new McpVerifyResultDto { FileName = fileName };
|
||||
|
||||
|
|
@ -625,26 +458,16 @@ public class McpDevelopAppService(
|
|||
/// ayni unit of work'te In Progress'e alinir. MCP yetki vermez; ekranin guncelleme yetkisi
|
||||
/// ayrica istenir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request)
|
||||
public async Task<McpDevRequestDto> StartDevRequestAsync(string request)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.StartDevRequest) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.StartDevRequest);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.StartDevRequest, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
|
||||
if (!string.Equals(devRequest.Status, McpConsts.DevRequestStatuses.ReadyToStart, StringComparison.Ordinal))
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
|
||||
}
|
||||
|
||||
devRequest.Status = McpConsts.DevRequestStatuses.InProgress;
|
||||
|
|
@ -654,64 +477,41 @@ public class McpDevelopAppService(
|
|||
return ToDto(devRequest);
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request)
|
||||
public async Task<McpDevRequestDto> GetDevRequestAsync(string request)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetDevRequest) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetDevRequest);
|
||||
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests);
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.GetDevRequest, PlatformConsts.AppCodes.DevRequests) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
await timesheetRecorder.BeginAsync(devRequest.Id);
|
||||
|
||||
return ToDto(devRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durum kurali modelin dikkatine birakilmaz: uretimi baslamamis (Backlog, Analysis, Ready to Start)
|
||||
/// talep analiz edilir; Ready to Start talebin analizi uretim oncesi yeniden yazilabilir. "Uretime hazir: Evet" analiz uretimin karar vermeden uygulayabilecegi analizdir,
|
||||
/// talep dogrudan Ready to Start'a gecer; sartli ya da Hayir olan analiz kullanicinin kararini
|
||||
/// bekledigi icin Analysis'te kalir.
|
||||
/// Durum kurali modelin dikkatine birakilmaz: yalnizca Backlog ya da Analysis durumundaki talep
|
||||
/// analiz edilir. Analysis'teki talebin analizi begenilmediyse yeniden yazilir; Ready to Start'a
|
||||
/// kullanici alir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady)
|
||||
public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.SaveDevRequestAnalysis, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestAnalysisRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]);
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
|
||||
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis or McpConsts.DevRequestStatuses.ReadyToStart))
|
||||
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis))
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
|
||||
}
|
||||
|
||||
devRequest.Analysis = html.Trim();
|
||||
devRequest.Status = productionReady
|
||||
? McpConsts.DevRequestStatuses.ReadyToStart
|
||||
: McpConsts.DevRequestStatuses.Analysis;
|
||||
devRequest.Status = McpConsts.DevRequestStatuses.Analysis;
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis);
|
||||
|
||||
|
|
@ -722,33 +522,20 @@ public class McpDevelopAppService(
|
|||
/// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize
|
||||
/// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir.
|
||||
/// </summary>
|
||||
public async Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html)
|
||||
public async Task AppendDevRequestNoteAsync(string request, string html)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.AppendDevRequestNote, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestNoteRequired);
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNoteRequired]);
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim());
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -756,44 +543,29 @@ public class McpDevelopAppService(
|
|||
/// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur.
|
||||
/// Var olan satirlara dokunulmaz.
|
||||
/// </summary>
|
||||
public async Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles)
|
||||
public async Task AddDevRequestSubTasksAsync(string request, List<string> titles)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.AddDevRequestSubTasks, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
|
||||
var items = (titles ?? []).Select(title => title?.Trim()).Where(title => !string.IsNullOrEmpty(title)).ToList();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestSubTasksRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]);
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
|
||||
JsonArray? subTasks;
|
||||
JsonArray subTasks;
|
||||
try
|
||||
{
|
||||
subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks)
|
||||
? new JsonArray()
|
||||
: JsonNode.Parse(devRequest.SubTasks) as JsonArray;
|
||||
? []
|
||||
: JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
subTasks = null;
|
||||
}
|
||||
|
||||
if (subTasks is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString());
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]);
|
||||
}
|
||||
|
||||
var assignee = CurrentUser.UserName ?? string.Empty;
|
||||
|
|
@ -814,26 +586,17 @@ public class McpDevelopAppService(
|
|||
devRequest.SubTasks = subTasks.ToJsonString();
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name)
|
||||
public async Task<ModuleDocDto> GetModuleDocAsync(string name)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
return await moduleDocAppService.GetByNameAsync(name);
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc);
|
||||
return await moduleDocAppService.GetAsync(name);
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content)
|
||||
public async Task<ModuleDocDto> SaveModuleDocAsync(string name, string content)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc);
|
||||
|
||||
var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content });
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name);
|
||||
|
|
@ -863,23 +626,23 @@ public class McpDevelopAppService(
|
|||
}
|
||||
|
||||
/// <summary>Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir.</summary>
|
||||
private async Task<McpOutcome<DevRequest>> FindDevRequestAsync(string? request)
|
||||
private async Task<DevRequest> FindDevRequestAsync(string? request)
|
||||
{
|
||||
var value = request?.Trim() ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(value, out var id))
|
||||
{
|
||||
var byId = await devRequestRepository.FindAsync(id);
|
||||
return byId is null ? Problem(McpErrorCodes.DevRequestNotFound, value) : byId;
|
||||
return await devRequestRepository.FindAsync(id)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]);
|
||||
}
|
||||
|
||||
var matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value);
|
||||
|
||||
return matches.Count switch
|
||||
{
|
||||
0 => Problem(McpErrorCodes.DevRequestNotFound, value),
|
||||
0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]),
|
||||
1 => matches[0],
|
||||
_ => Problem(McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id)))
|
||||
_ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -986,16 +749,13 @@ public class McpDevelopAppService(
|
|||
private static McpVerifyCheckDto Failed(string name, string message)
|
||||
=> new() { Name = name, Passed = false, Message = message };
|
||||
|
||||
public async Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search)
|
||||
public async Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListComponents) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListComponents);
|
||||
|
||||
var components = await GetAllComponentsAsync();
|
||||
|
||||
return components
|
||||
return [.. components
|
||||
.Where(component => string.IsNullOrWhiteSpace(search)
|
||||
|| Contains(component.Name, search)
|
||||
|| Contains(component.RoutePath, search)
|
||||
|
|
@ -1008,31 +768,24 @@ public class McpDevelopAppService(
|
|||
Description = component.Description,
|
||||
IsActive = component.IsActive,
|
||||
IsVisualDesigner = IsVisualDesignerDocument(component.Props)
|
||||
})
|
||||
.ToList();
|
||||
})];
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name)
|
||||
public async Task<CustomComponentDto?> GetComponentAsync(string name)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetComponent);
|
||||
|
||||
return await FindComponentAsync(name);
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input)
|
||||
public async Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveComponent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
return Problem(McpErrorCodes.ComponentNameRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]);
|
||||
}
|
||||
|
||||
var existing = await FindComponentAsync(input.Name);
|
||||
|
|
@ -1042,14 +795,14 @@ public class McpDevelopAppService(
|
|||
// kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir.
|
||||
if (IsVisualDesignerDocument(props))
|
||||
{
|
||||
return Problem(McpErrorCodes.VisualDesignerComponent, input.Name);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]);
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Code))
|
||||
{
|
||||
return Problem(McpErrorCodes.ComponentCodeRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]);
|
||||
}
|
||||
|
||||
var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto
|
||||
|
|
@ -1082,44 +835,52 @@ public class McpDevelopAppService(
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async Task<McpProblem?> DeleteComponentAsync(string name)
|
||||
public async Task DeleteComponentAsync(string name)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeleteComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteComponent);
|
||||
|
||||
var component = await FindComponentAsync(name);
|
||||
if (component is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ComponentNotFound, name);
|
||||
}
|
||||
var component = await FindComponentAsync(name)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]);
|
||||
|
||||
await componentAppService.DeleteAsync(component.Id);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>, basliklar toplanmis) ya da duz
|
||||
/// cevaplardan okur; Menu/Language/Data/Details/Layout bloklari ve WizardCode/ModuleId adlari
|
||||
/// dosyadakiyle ayni anlama gelir (<see cref="WizardSeedFileDto.FromDeployJson"/>).
|
||||
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>) ya da duz cevaplardan okur.
|
||||
/// Iki bicim de dosyanin okuma kuralindan gecer; boylece Menu/Language bloklari ve
|
||||
/// WizardCode/ModuleId adlari dosyadakiyle ayni anlama gelir.
|
||||
/// </summary>
|
||||
private McpOutcome<ListFormWizardDto> ParseWizard(string? json)
|
||||
private ListFormWizardDto ParseWizard(string? json)
|
||||
{
|
||||
ListFormWizardDto? wizard;
|
||||
JsonObject? root;
|
||||
try
|
||||
{
|
||||
wizard = string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json);
|
||||
root = string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json) as JsonObject;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
catch (JsonException)
|
||||
{
|
||||
// Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir.
|
||||
return Problem(McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message);
|
||||
root = null;
|
||||
}
|
||||
|
||||
return wizard is null ? Problem(McpErrorCodes.InvalidWizard) : wizard;
|
||||
if (root is null)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
|
||||
}
|
||||
|
||||
var seedJson = root.ContainsKey(nameof(WizardSeedFileDto.Wizard))
|
||||
? root.ToJsonString()
|
||||
: new JsonObject { [nameof(WizardSeedFileDto.Wizard)] = root }.ToJsonString();
|
||||
|
||||
try
|
||||
{
|
||||
return WizardSeedFileDto.FromSeedJson(seedJson)?.Wizard
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync()
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@ using System.Collections.Generic;
|
|||
using System.Data.Common;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.ListForms.Select;
|
||||
|
|
@ -34,52 +30,39 @@ namespace Sozsoft.Mcp;
|
|||
/// <see cref="IMcpQueryExecutor"/> icindedir.
|
||||
/// </summary>
|
||||
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
|
||||
public partial class McpQueryAppService(
|
||||
public class McpQueryAppService(
|
||||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpSqlGuard sqlGuard,
|
||||
IMcpQueryExecutor queryExecutor,
|
||||
IRepository<CustomEndpoint, Guid> customEndpointRepository,
|
||||
IListFormSelectAppService listFormSelectAppService,
|
||||
IMcpListFormAccessFilter accessFilter,
|
||||
IConfiguration configuration)
|
||||
IMcpListFormAccessFilter accessFilter)
|
||||
: McpAppService(serverConfigProvider), IMcpQueryAppService
|
||||
{
|
||||
private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default;
|
||||
private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get;
|
||||
|
||||
public async Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input)
|
||||
public async Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.QueryList)).TryGetValue(out var config, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.QueryList);
|
||||
var take = ResolveMaxRows(config, input.Take);
|
||||
|
||||
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read);
|
||||
if (permission is not null && await CheckPermissionAsync(McpToolNames.QueryList, permission) is { } denied)
|
||||
if (permission is not null)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort).TryGetValue(out var sort, out problem) ||
|
||||
!ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup).TryGetValue(out var group, out problem) ||
|
||||
!ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary).TryGetValue(out var groupSummary, out problem) ||
|
||||
!ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary).TryGetValue(out var totalSummary, out problem))
|
||||
{
|
||||
return problem;
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
}
|
||||
|
||||
var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
|
||||
{
|
||||
ListFormCode = input.ListFormCode,
|
||||
Filter = NullIfEmpty(input.Filter),
|
||||
Sort = sort,
|
||||
Group = group,
|
||||
GroupSummary = groupSummary,
|
||||
TotalSummary = totalSummary,
|
||||
Sort = ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort),
|
||||
Group = ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup),
|
||||
GroupSummary = ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary),
|
||||
TotalSummary = ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary),
|
||||
Skip = Math.Max(input.Skip, 0),
|
||||
Take = take,
|
||||
RequireTotalCount = true,
|
||||
|
|
@ -97,7 +80,7 @@ public partial class McpQueryAppService(
|
|||
}
|
||||
|
||||
/// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary>
|
||||
private McpOutcome<string?> ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
|
||||
private string? ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -105,31 +88,21 @@ public partial class McpQueryAppService(
|
|||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter));
|
||||
throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input)
|
||||
public async Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect);
|
||||
|
||||
var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim();
|
||||
if (sqlGuard.CheckReadOnlyStatement(sql) is { } invalid)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
sqlGuard.EnsureReadOnlyStatement(sql);
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType);
|
||||
|
||||
|
|
@ -142,102 +115,13 @@ public partial class McpQueryAppService(
|
|||
}
|
||||
catch (DbException ex)
|
||||
{
|
||||
var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null;
|
||||
if (pending is not null)
|
||||
return Problem(McpErrorCodes.SelectTableNotInstalled, ex.Message, pending);
|
||||
|
||||
var columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null;
|
||||
return columns is null
|
||||
? Problem(McpErrorCodes.SelectFailed, ex.Message)
|
||||
: Problem(McpErrorCodes.SelectFailedColumns, ex.Message, columns);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SelectFailed, ex.Message]);
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxDescribedTables = 5;
|
||||
|
||||
/// <summary>SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column).</summary>
|
||||
private static bool IsUnknownColumnError(DbException ex) =>
|
||||
ex.SqlState == "42703" || ex.Message.Contains("Invalid column name", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>SQL Server 208 ("Invalid object name"), PostgreSQL 42P01 (undefined_table).</summary>
|
||||
private static bool IsUnknownObjectError(DbException ex) =>
|
||||
ex.SqlState == "42P01" || ex.Message.Contains("Invalid object name", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Sorgunun andigi ve veritabaninda olmayan tablolardan seed scripti olanlar (<c>Tablo(A, B); …</c>).
|
||||
/// <c>create_table</c> tabloyu yalnizca scripte yazar, kurulum DB Migrate'tedir; model ayni talepte
|
||||
/// tabloyu sorgularsa kolon tahmini yuzunden degil kurulum beklendigi icin bos doner. Kolonlar
|
||||
/// scriptten okunur, boylece model sema araci cagirmadan ilerler. Scripti olan tablo yoksa null.
|
||||
/// </summary>
|
||||
private string? DescribePendingTables(string sql)
|
||||
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
|
||||
{
|
||||
var described = TableReferenceRegex().Matches(sql)
|
||||
.Select(match => match.Groups["table"].Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxDescribedTables)
|
||||
.Select(table => (Table: table, Columns: PostgreSqlTableScript.ReadSeedColumns(configuration, table)))
|
||||
.Where(item => item.Columns.Count > 0)
|
||||
.Select(item => $"{item.Table}({string.Join(", ", item.Columns.Keys)})")
|
||||
.ToList();
|
||||
|
||||
return described.Count == 0 ? null : string.Join("; ", described);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (<c>Tablo(A, B, C); …</c>). Model
|
||||
/// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir.
|
||||
/// Kolonlar okunamazsa null doner; asil hata yine iletilir.
|
||||
/// </summary>
|
||||
private async Task<string?> DescribeTableColumnsAsync(McpDataSourceContext context, string sql)
|
||||
{
|
||||
var tables = TableReferenceRegex().Matches(sql)
|
||||
.Select(match => match.Groups["table"].Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxDescribedTables)
|
||||
.ToList();
|
||||
if (tables.Count == 0)
|
||||
return null;
|
||||
|
||||
var parameters = tables.Select((table, index) => (Name: $"t{index}", Table: table))
|
||||
.ToDictionary(p => p.Name, p => (object)p.Table);
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await queryExecutor.ExecuteAsync(
|
||||
context,
|
||||
$"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN ({string.Join(", ", parameters.Keys.Select(name => "@" + name))}) ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||
parameters,
|
||||
int.MaxValue);
|
||||
|
||||
var described = rows.Rows
|
||||
.Select(row => (Table: ReadIgnoreCase(row, "TABLE_NAME"), Column: ReadIgnoreCase(row, "COLUMN_NAME")))
|
||||
.Where(item => item.Table is not null && item.Column is not null)
|
||||
.GroupBy(item => item.Table!, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => $"{group.Key}({string.Join(", ", group.Select(item => item.Column))})")
|
||||
.ToList();
|
||||
|
||||
return described.Count == 0 ? null : string.Join("; ", described);
|
||||
}
|
||||
catch (DbException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadIgnoreCase(Dictionary<string, object?> row, string key) =>
|
||||
row.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
|
||||
|
||||
/// <summary>FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir (<c>[dbo].[Tablo]</c> → <c>Tablo</c>).</summary>
|
||||
[GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?<table>\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex TableReferenceRegex();
|
||||
|
||||
public async Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListQueries) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListQueries);
|
||||
var results = new List<McpSavedQueryDto>();
|
||||
|
||||
if (await AuthorizationService.IsGrantedAsync(SqlPermission))
|
||||
|
|
@ -257,16 +141,17 @@ public partial class McpQueryAppService(
|
|||
|| (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))];
|
||||
}
|
||||
|
||||
return results
|
||||
return [.. results
|
||||
.OrderBy(item => item.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)];
|
||||
}
|
||||
|
||||
private async Task<List<McpSavedQueryDto>> GetDatabaseObjectsAsync()
|
||||
{
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
var catalogSql = SqlCatalogQueries.NativeObjects(context.DataSourceType);
|
||||
var catalogSql = context.DataSourceType == DataSourceTypeEnum.Postgresql
|
||||
? McpCatalogQueries.Postgres
|
||||
: McpCatalogQueries.SqlServer;
|
||||
|
||||
// Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz.
|
||||
var catalog = await queryExecutor.ExecuteAsync(context, catalogSql, new Dictionary<string, object>(), int.MaxValue);
|
||||
|
|
@ -292,58 +177,44 @@ public partial class McpQueryAppService(
|
|||
return results;
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input)
|
||||
public async Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem))
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery);
|
||||
|
||||
if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint))
|
||||
{
|
||||
return problem;
|
||||
await AuthorizationService.CheckAsync(CustomEndpointPermission);
|
||||
return await RunCustomEndpointAsync(input, config);
|
||||
}
|
||||
|
||||
var isCustomEndpoint = IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint);
|
||||
if (await CheckPermissionAsync(McpToolNames.RunQuery, isCustomEndpoint ? CustomEndpointPermission : SqlPermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await AuthorizationService.CheckAsync(SqlPermission);
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
if (isCustomEndpoint)
|
||||
{
|
||||
return await RunCustomEndpointAsync(input, parameters, maxRows);
|
||||
}
|
||||
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
|
||||
var schemaName = string.IsNullOrWhiteSpace(input.SchemaName)
|
||||
? SqlIdentifier.DefaultSchema(context.DataSourceType)
|
||||
? sqlGuard.DefaultSchemaName(context.DataSourceType)
|
||||
: input.SchemaName;
|
||||
|
||||
if (!sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType).TryGetValue(out var qualifiedName, out problem) ||
|
||||
!BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType).TryGetValue(out var sql, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType);
|
||||
var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType);
|
||||
|
||||
return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows));
|
||||
}
|
||||
|
||||
private async Task<McpOutcome<McpQueryResultDto>> RunCustomEndpointAsync(
|
||||
private async Task<McpQueryResultDto> RunCustomEndpointAsync(
|
||||
McpSavedQueryRequestDto input,
|
||||
Dictionary<string, object> parameters,
|
||||
int maxRows)
|
||||
McpServerConfigDto config)
|
||||
{
|
||||
var endpoints = await GetCallableEndpointEntitiesAsync();
|
||||
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (endpoint is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.CustomEndpointNotFound, input.Name);
|
||||
}
|
||||
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.CustomEndpointNotFound, input.Name]);
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
|
||||
// Eksik parametre Dapper tarafinda belirsiz bir hataya donusmesin diye burada yakalanir.
|
||||
var missing = endpoint.Parameters
|
||||
|
|
@ -353,7 +224,7 @@ public partial class McpQueryAppService(
|
|||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing));
|
||||
throw new UserFriendlyException(L[McpErrorCodes.MissingParameters, string.Join(", ", missing)]);
|
||||
}
|
||||
|
||||
var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode);
|
||||
|
|
@ -389,7 +260,7 @@ public partial class McpQueryAppService(
|
|||
|| (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))];
|
||||
}
|
||||
|
||||
private McpOutcome<string> BuildSavedQuerySql(
|
||||
private string BuildSavedQuerySql(
|
||||
string? kind,
|
||||
string qualifiedName,
|
||||
Dictionary<string, object> parameters,
|
||||
|
|
@ -421,7 +292,7 @@ public partial class McpQueryAppService(
|
|||
: $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd();
|
||||
}
|
||||
|
||||
return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty]);
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
|
@ -36,17 +36,15 @@ public partial class McpReportAppService(
|
|||
private const string CategoriesListFormCode = PlatformConsts.AppCodes.Reports.Categories;
|
||||
private const string TemplatesListFormCode = PlatformConsts.AppCodes.Reports.ReportTemplates;
|
||||
private const string ActiveStatus = "active";
|
||||
private const string DefaultButtonText = "Sas.Platform.View";
|
||||
private const string DefaultButtonText = "App.Platform.View";
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
public async Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search)
|
||||
public async Task<McpReportCatalogDto> GetReportTemplatesAsync(string? search)
|
||||
{
|
||||
if (await EnsureCanReadAsync(McpToolNames.ListReportTemplates) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListReportTemplates);
|
||||
await EnsureCanReadAsync();
|
||||
|
||||
var categories = await categoryRepository.GetListAsync();
|
||||
var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
|
@ -72,35 +70,25 @@ public partial class McpReportAppService(
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template)
|
||||
public async Task<McpReportTemplateDto> GetReportTemplateAsync(string template)
|
||||
{
|
||||
if (await EnsureCanReadAsync(McpToolNames.GetReportTemplate) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetReportTemplate);
|
||||
await EnsureCanReadAsync();
|
||||
|
||||
var entity = await FindTemplateAsync(template);
|
||||
if (entity is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
|
||||
}
|
||||
var entity = await FindTemplateAsync(template)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
|
||||
|
||||
return await ToDtoAsync(entity, null, null);
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
|
||||
public async Task<McpReportCategoryDto> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
const string toolName = McpToolNames.SaveReportCategory;
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveReportCategory);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportNameRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
|
||||
}
|
||||
|
||||
var name = input.Name.Trim();
|
||||
|
|
@ -112,33 +100,24 @@ public partial class McpReportAppService(
|
|||
["Icon"] = input.Icon ?? existing?.Icon
|
||||
};
|
||||
|
||||
var saved = existing is null
|
||||
? await InsertAsync(toolName, CategoriesListFormCode, values)
|
||||
: await UpdateAsync(toolName, CategoriesListFormCode, existing.Id, values);
|
||||
if (!saved.TryGetValue(out var id, out var rejected))
|
||||
{
|
||||
return rejected;
|
||||
}
|
||||
var id = existing is null
|
||||
? await InsertAsync(CategoriesListFormCode, values)
|
||||
: await UpdateAsync(CategoriesListFormCode, existing.Id, values);
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name);
|
||||
|
||||
var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return category is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(category);
|
||||
var saved = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return saved is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(saved);
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
|
||||
public async Task<McpReportTemplateDto> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
const string toolName = McpToolNames.SaveReportTemplate;
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveReportTemplate);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportNameRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
|
||||
}
|
||||
|
||||
var name = input.Name.Trim();
|
||||
|
|
@ -146,32 +125,22 @@ public partial class McpReportAppService(
|
|||
var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent;
|
||||
if (string.IsNullOrWhiteSpace(htmlContent))
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportHtmlRequired);
|
||||
}
|
||||
|
||||
var categoryId = existing?.CategoryId;
|
||||
if (!string.IsNullOrWhiteSpace(input.Category))
|
||||
{
|
||||
categoryId = (await FindCategoryAsync(input.Category))?.Id;
|
||||
if (categoryId is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportCategoryNotFound, input.Category);
|
||||
}
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportHtmlRequired]);
|
||||
}
|
||||
|
||||
var categoryId = string.IsNullOrWhiteSpace(input.Category)
|
||||
? existing?.CategoryId
|
||||
: (await FindCategoryAsync(input.Category))?.Id
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryNotFound, input.Category]);
|
||||
if (categoryId is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportCategoryRequired);
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryRequired]);
|
||||
}
|
||||
|
||||
if (!(await FindListFormAsync(input.ListFormCode)).TryGetValue(out var listForm, out var notFound))
|
||||
var listForm = await FindListFormAsync(input.ListFormCode);
|
||||
if (listForm is not null)
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (listForm is not null && await CheckPlaceholdersExistAsync(listForm.ListFormCode, htmlContent) is { } unknown)
|
||||
{
|
||||
return unknown;
|
||||
await EnsurePlaceholdersExistAsync(listForm.ListFormCode, htmlContent);
|
||||
}
|
||||
|
||||
var values = new Dictionary<string, object?>
|
||||
|
|
@ -183,74 +152,44 @@ public partial class McpReportAppService(
|
|||
["Status"] = input.Status ?? existing?.Status ?? ActiveStatus
|
||||
};
|
||||
|
||||
var saved = existing is null
|
||||
? await InsertAsync(toolName, TemplatesListFormCode, values)
|
||||
: await UpdateAsync(toolName, TemplatesListFormCode, existing.Id, values);
|
||||
if (!saved.TryGetValue(out var id, out var rejected))
|
||||
{
|
||||
return rejected;
|
||||
}
|
||||
var id = existing is null
|
||||
? await InsertAsync(TemplatesListFormCode, values)
|
||||
: await UpdateAsync(TemplatesListFormCode, existing.Id, values);
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name);
|
||||
|
||||
var template = await templateRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (template is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, name);
|
||||
}
|
||||
var saved = await templateRepository.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, name]);
|
||||
|
||||
return await ToDtoAsync(template, listForm, input.ButtonText);
|
||||
return await ToDtoAsync(saved, listForm, input.ButtonText);
|
||||
}
|
||||
|
||||
public async Task<McpProblem?> DeleteReportTemplateAsync(string template)
|
||||
public async Task DeleteReportTemplateAsync(string template)
|
||||
{
|
||||
const string toolName = McpToolNames.DeleteReportTemplate;
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteReportTemplate);
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
var entity = await FindTemplateAsync(template)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
|
||||
|
||||
var entity = await FindTemplateAsync(template);
|
||||
if (entity is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
|
||||
}
|
||||
|
||||
if (!(await BuildRequestAsync(toolName, TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id)).TryGetValue(out var request, out var denied))
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
await listFormDataAppService.PostDeleteAsync(request);
|
||||
await listFormDataAppService.PostDeleteAsync(await BuildRequestAsync(
|
||||
TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id));
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteReportTemplate, entity.Name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin
|
||||
/// uygulamada goremedigi sablonu gostermez.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> EnsureCanReadAsync(string toolName)
|
||||
private async Task EnsureCanReadAsync()
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read);
|
||||
return await CheckPermissionAsync(toolName, permission ?? TemplatesListFormCode);
|
||||
await AuthorizationService.CheckAsync(permission ?? TemplatesListFormCode);
|
||||
}
|
||||
|
||||
private async Task<McpOutcome<Guid>> InsertAsync(string toolName, string listFormCode, Dictionary<string, object?> values)
|
||||
private async Task<Guid> InsertAsync(string listFormCode, Dictionary<string, object?> values)
|
||||
{
|
||||
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Create, values, null)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var key = await listFormDataAppService.PostInsertAsync(request);
|
||||
var key = await listFormDataAppService.PostInsertAsync(await BuildRequestAsync(
|
||||
listFormCode, AuthorizationTypeEnum.Create, values, null));
|
||||
|
||||
// Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur.
|
||||
if (Guid.TryParse(((object?)key)?.ToString(), out var id))
|
||||
|
|
@ -264,14 +203,10 @@ public partial class McpReportAppService(
|
|||
: (await templateRepository.FirstAsync(x => x.Name == name)).Id;
|
||||
}
|
||||
|
||||
private async Task<McpOutcome<Guid>> UpdateAsync(string toolName, string listFormCode, Guid id, Dictionary<string, object?> values)
|
||||
private async Task<Guid> UpdateAsync(string listFormCode, Guid id, Dictionary<string, object?> values)
|
||||
{
|
||||
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Update, values, id)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await listFormDataAppService.PostUpdateAsync(request);
|
||||
await listFormDataAppService.PostUpdateAsync(await BuildRequestAsync(
|
||||
listFormCode, AuthorizationTypeEnum.Update, values, id));
|
||||
return id;
|
||||
}
|
||||
|
||||
|
|
@ -279,17 +214,16 @@ public partial class McpReportAppService(
|
|||
/// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
|
||||
/// istemci hangi yetkinin eksik oldugunu gorsun.
|
||||
/// </summary>
|
||||
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
|
||||
string toolName,
|
||||
private async Task<DataRequestDto> BuildRequestAsync(
|
||||
string listFormCode,
|
||||
AuthorizationTypeEnum operation,
|
||||
Dictionary<string, object?>? values,
|
||||
Guid? key)
|
||||
{
|
||||
var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation);
|
||||
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
if (permission is not null)
|
||||
{
|
||||
return denied;
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
}
|
||||
|
||||
return new DataRequestDto
|
||||
|
|
@ -321,34 +255,32 @@ public partial class McpReportAppService(
|
|||
: await categoryRepository.FirstOrDefaultAsync(x => x.Name == value);
|
||||
}
|
||||
|
||||
/// <summary>Kod verilmediyse null; verilen kodun ekrani yoksa sorun.</summary>
|
||||
private async Task<McpOutcome<ListForm?>> FindListFormAsync(string? listFormCode)
|
||||
private async Task<ListForm?> FindListFormAsync(string? listFormCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listFormCode))
|
||||
{
|
||||
return default(ListForm);
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = listFormCode.Trim();
|
||||
var listForm = await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code);
|
||||
|
||||
return listForm is null ? Problem(McpErrorCodes.ReportListFormNotFound, code) : listForm;
|
||||
return await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportListFormNotFound, code]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin
|
||||
/// diye kayitta reddedilir.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> CheckPlaceholdersExistAsync(string listFormCode, string htmlContent)
|
||||
private async Task EnsurePlaceholdersExistAsync(string listFormCode, string htmlContent)
|
||||
{
|
||||
var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode))
|
||||
.Select(x => x.FieldName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList();
|
||||
|
||||
return unknown.Count == 0
|
||||
? null
|
||||
: Problem(McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown));
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)]);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetPlaceholders(string htmlContent)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.ListForms;
|
||||
using Volo.Abp.Authorization.Permissions;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
|
@ -26,17 +20,9 @@ public class McpSchemaAppService(
|
|||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpSchemaDictionary dictionary,
|
||||
IMcpListFormAccessFilter accessFilter,
|
||||
IPermissionChecker permissionChecker,
|
||||
IListFormWizardAppService wizardAppService,
|
||||
IConfiguration configuration)
|
||||
IPermissionChecker permissionChecker)
|
||||
: McpAppService(serverConfigProvider), IMcpSchemaAppService
|
||||
{
|
||||
/// <summary>Wizard dosyasi olan ama sozlukte olmayan ekran (DB Migrate bekliyor).</summary>
|
||||
private const string PendingSourceKind = "pending";
|
||||
|
||||
/// <summary>Yalnizca seed scripti olan, ekrani olmayan tablo.</summary>
|
||||
private const string PendingTableSourceKind = "pendingTable";
|
||||
|
||||
public async Task<McpServerConfigDto?> GetServerConfigAsync()
|
||||
=> await ResolveServerConfigAsync();
|
||||
|
||||
|
|
@ -48,9 +34,20 @@ public class McpSchemaAppService(
|
|||
CurrentTenant.IsAvailable,
|
||||
config?.AllowedTools ?? []);
|
||||
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(candidates
|
||||
var required = candidates
|
||||
.Where(McpToolNames.RequiredPermissions.ContainsKey)
|
||||
.Select(tool => McpToolNames.RequiredPermissions[tool]));
|
||||
.Select(tool => McpToolNames.RequiredPermissions[tool])
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var granted = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (required.Length > 0)
|
||||
{
|
||||
var grants = await permissionChecker.IsGrantedAsync(required);
|
||||
granted.UnionWith(grants.Result
|
||||
.Where(result => result.Value == PermissionGrantResult.Granted)
|
||||
.Select(result => result.Key));
|
||||
}
|
||||
|
||||
return [.. candidates.Where(tool =>
|
||||
!McpToolNames.RequiredPermissions.TryGetValue(tool, out var permission) || granted.Contains(permission))];
|
||||
|
|
@ -82,12 +79,9 @@ public class McpSchemaAppService(
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200)
|
||||
public async Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListLists) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListLists);
|
||||
|
||||
var forms = await GetReadableListFormsAsync();
|
||||
var normalizedSearch = dictionary.Normalize(search);
|
||||
|
|
@ -95,19 +89,16 @@ public class McpSchemaAppService(
|
|||
var matches = forms.Where(form => normalizedSearch.Length == 0
|
||||
|| dictionary.Normalize(form.SearchText).Contains(normalizedSearch, StringComparison.Ordinal));
|
||||
|
||||
return matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary).ToList();
|
||||
return [.. matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary)];
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode)
|
||||
public async Task<McpListDetailDto?> GetListAsync(string listFormCode)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DescribeList) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.DescribeList);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listFormCode))
|
||||
{
|
||||
return default(McpListDetailDto);
|
||||
return null;
|
||||
}
|
||||
|
||||
var forms = await GetReadableListFormsAsync();
|
||||
|
|
@ -116,7 +107,7 @@ public class McpSchemaAppService(
|
|||
|
||||
if (form is null)
|
||||
{
|
||||
return default(McpListDetailDto);
|
||||
return null;
|
||||
}
|
||||
|
||||
var detail = new McpListDetailDto();
|
||||
|
|
@ -126,17 +117,14 @@ public class McpSchemaAppService(
|
|||
return detail;
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50)
|
||||
public async Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SearchSchema) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
await EnsureToolAllowedAsync(McpToolNames.SearchSchema);
|
||||
|
||||
var normalized = dictionary.Normalize(term);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return new List<McpSchemaMatchDto>();
|
||||
return [];
|
||||
}
|
||||
|
||||
var limit = NormalizeLimit(maxResults, 50);
|
||||
|
|
@ -180,93 +168,7 @@ public class McpSchemaAppService(
|
|||
var forms = await dictionary.GetListFormsAsync();
|
||||
var readable = await accessFilter.GetReadableListFormCodesAsync();
|
||||
|
||||
List<McpDictionaryListForm> result = [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))];
|
||||
|
||||
// Gelistirici, sozlukte henuz olmayan (DB Migrate bekleyen ya da sozluk tazelenmemis) ekran ve
|
||||
// tablolari da gorur; lookup, widget ve view SQL'i bunlarin gercek kolon adlariyla yazilir.
|
||||
if (!CurrentTenant.IsAvailable && await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
|
||||
result.AddRange(await GetSeedOnlySourcesAsync(forms));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed klasorunde olup sozlukte olmayan kaynaklar: wizard dosyalarinin ekranlari (alanlari
|
||||
/// <c>Groups</c> alanlarindan) ve yalnizca <c>sql/object</c> scripti olan tablolar (kolonlari scriptten).
|
||||
/// <see cref="McpListSummaryDto.SourceKind"/> <c>pending</c> / <c>pendingTable</c> olarak isaretlenir:
|
||||
/// kolon adlari dogrudur ama query_list migrate'ten once calismaz.
|
||||
/// </summary>
|
||||
private async Task<List<McpDictionaryListForm>> GetSeedOnlySourcesAsync(IReadOnlyList<McpDictionaryListForm> known)
|
||||
{
|
||||
var codes = known.Select(form => form.ListFormCode).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var tables = known.Select(form => form.TableName).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<McpDictionaryListForm>();
|
||||
|
||||
foreach (var file in await wizardAppService.GetFiles())
|
||||
{
|
||||
if (file.ComponentKind == WizardComponentKindEnum.Custom || codes.Contains(file.ListFormCode ?? string.Empty))
|
||||
continue;
|
||||
|
||||
var seed = await wizardAppService.GetFile(file.FileName);
|
||||
if (seed.Wizard is not { } wizard || string.IsNullOrWhiteSpace(wizard.SelectCommand))
|
||||
continue;
|
||||
|
||||
// Alanlar dosyadaki cevaplardan ekranin sozlesmesine cevrilir; sozluk ekranla ayni degerleri gorur.
|
||||
var listForm = wizard.BuildListForm();
|
||||
|
||||
var table = listForm.SelectCommandType == SelectCommandTypeEnum.Query ? null : listForm.SelectCommand?.Split('.')[^1].Trim('[', ']', '"');
|
||||
if (table is not null)
|
||||
tables.Add(table);
|
||||
|
||||
result.Add(new McpDictionaryListForm
|
||||
{
|
||||
ListFormCode = listForm.ListFormCode,
|
||||
DisplayName = seed.Wizard?.TitleTr ?? seed.Wizard?.TitleEn,
|
||||
Title = seed.Wizard?.TitleEn,
|
||||
Description = seed.Wizard?.DescTr,
|
||||
TableName = table,
|
||||
SqlSourceName = listForm.SelectCommand,
|
||||
SourceKind = PendingSourceKind,
|
||||
KeyFieldName = listForm.KeyFieldName,
|
||||
SearchText = string.Join(' ', listForm.ListFormCode, seed.Wizard?.TitleTr, seed.Wizard?.TitleEn, table),
|
||||
Fields = [.. wizard.BuildFields().Select(field => new McpDictionaryField
|
||||
{
|
||||
Field = field.FieldName,
|
||||
DisplayName = field.CaptionName,
|
||||
SqlDataType = field.SourceDbType.ToString(),
|
||||
IsVisible = field.Visible,
|
||||
IsBooleanColumn = field.SourceDbType == DbType.Boolean,
|
||||
LookupValueColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.ValueExpr,
|
||||
LookupDisplayColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.DisplayExpr,
|
||||
IsDefaultSelectColumn = field.Visible
|
||||
})]
|
||||
});
|
||||
}
|
||||
|
||||
var scriptFolder = SeedPathResolver.GetPath(configuration, SeedPathResolver.SqlFolder, SeedPathResolver.ObjectFolder);
|
||||
foreach (var path in Directory.Exists(scriptFolder) ? Directory.GetFiles(scriptFolder, "*.sql") : [])
|
||||
{
|
||||
var table = Path.GetFileNameWithoutExtension(path);
|
||||
if (tables.Contains(table))
|
||||
continue;
|
||||
|
||||
var columns = PostgreSqlTableScript.ReadSeedColumns(configuration, table);
|
||||
if (columns.Count == 0)
|
||||
continue;
|
||||
|
||||
result.Add(new McpDictionaryListForm
|
||||
{
|
||||
ListFormCode = table,
|
||||
TableName = table,
|
||||
SqlSourceName = table,
|
||||
SourceKind = PendingTableSourceKind,
|
||||
KeyFieldName = columns.ContainsKey("Id") ? "Id" : null,
|
||||
SearchText = table,
|
||||
Fields = [.. columns.Keys.Select(column => new McpDictionaryField { Field = column, IsVisible = true, IsDefaultSelectColumn = true })]
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))];
|
||||
}
|
||||
|
||||
private static McpListSummaryDto ToSummary(McpDictionaryListForm form)
|
||||
|
|
|
|||
|
|
@ -44,13 +44,6 @@ public static class McpConsts
|
|||
/// </summary>
|
||||
public const string OAuthClientId = "Platform_Mcp";
|
||||
|
||||
/// <summary>
|
||||
/// Yerel makinedeki MCP proxy'sinin (mcp-remote) istemci kimligi. Geri donus adresleri
|
||||
/// DbMigrator'da <c>OpenIddict:Applications:Platform_Mcp_Local</c> ayarindadir. Loopback adresine
|
||||
/// makinedeki her surec dinleyebildigi icin acik onay ister.
|
||||
/// </summary>
|
||||
public const string LocalOAuthClientId = "Platform_Mcp_Local";
|
||||
|
||||
/// <summary>
|
||||
/// Dinamik kayitla (RFC 7591) uretilen istemcilerin kimlik oneki. Bilinen saglayicilarin
|
||||
/// (Claude, ChatGPT) disindaki MCP istemcileri bu onekle kendi OpenIddict uygulamasini alir.
|
||||
|
|
@ -74,7 +67,6 @@ public static class McpConsts
|
|||
public static bool IsMcpClient(string? clientId)
|
||||
=> clientId is not null
|
||||
&& (string.Equals(clientId, OAuthClientId, System.StringComparison.Ordinal)
|
||||
|| string.Equals(clientId, LocalOAuthClientId, System.StringComparison.Ordinal)
|
||||
|| clientId.StartsWith(DynamicClientIdPrefix, System.StringComparison.Ordinal));
|
||||
|
||||
/// <summary>MCP ucunun sunucu adresine gore yolu.</summary>
|
||||
|
|
@ -106,7 +98,7 @@ public static class McpConsts
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gelistirme talebi (<c>Sas.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup
|
||||
/// Gelistirme talebi (<c>App.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup
|
||||
/// anahtarlariyla ayni olmak zorundadir.
|
||||
/// </summary>
|
||||
public static class DevRequestStatuses
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace Sozsoft.Mcp;
|
|||
|
||||
/// <summary>
|
||||
/// Modulun paylasilan sozlesmesi. Kullaniciya gorunen metinler platformun kendi
|
||||
/// <c>PlatformResource</c> kaynagindan (configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json) okunur; modul ikinci bir dil
|
||||
/// <c>PlatformResource</c> kaynagindan (configs/seeds/host/data/App.Languages.LanguageText.json) okunur; modul ikinci bir dil
|
||||
/// kaynagi acmaz.
|
||||
/// </summary>
|
||||
[DependsOn(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json</c>
|
||||
/// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/host/data/App.Languages.LanguageText.json</c>
|
||||
/// dosyasinda EN + TR olarak durur; koda gomulu metin kullanilmaz.
|
||||
/// </summary>
|
||||
public static class McpErrorCodes
|
||||
{
|
||||
private const string Prefix = "Sas.Mcp.Error.";
|
||||
private const string Prefix = "App.Mcp.Error.";
|
||||
|
||||
public const string NoServerRecord = Prefix + "NoServerRecord";
|
||||
public const string ToolDisabled = Prefix + "ToolDisabled";
|
||||
|
|
@ -33,12 +33,9 @@ public static class McpErrorCodes
|
|||
public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound";
|
||||
public const string DevGuideSearchMatches = Prefix + "DevGuideSearchMatches";
|
||||
public const string InvalidWizard = Prefix + "InvalidWizard";
|
||||
public const string InvalidWizardValue = Prefix + "InvalidWizardValue";
|
||||
public const string WizardCodeMissing = Prefix + "WizardCodeMissing";
|
||||
public const string SeedRowsInvalid = Prefix + "SeedRowsInvalid";
|
||||
public const string SeedRowsNotAllowed = Prefix + "SeedRowsNotAllowed";
|
||||
public const string SeedRowsUnknownFields = Prefix + "SeedRowsUnknownFields";
|
||||
public const string SeedRowsFieldsOnOtherScreens = Prefix + "SeedRowsFieldsOnOtherScreens";
|
||||
public const string ComponentNameRequired = Prefix + "ComponentNameRequired";
|
||||
public const string ComponentCodeRequired = Prefix + "ComponentCodeRequired";
|
||||
public const string ComponentNotFound = Prefix + "ComponentNotFound";
|
||||
|
|
@ -51,8 +48,6 @@ public static class McpErrorCodes
|
|||
public const string VerifySelectPassed = Prefix + "VerifySelectPassed";
|
||||
public const string VerifySelectFailed = Prefix + "VerifySelectFailed";
|
||||
public const string SelectFailed = Prefix + "SelectFailed";
|
||||
public const string SelectFailedColumns = Prefix + "SelectFailedColumns";
|
||||
public const string SelectTableNotInstalled = Prefix + "SelectTableNotInstalled";
|
||||
public const string VerifyComponentInactive = Prefix + "VerifyComponentInactive";
|
||||
public const string VerifyComponentRouteMismatch = Prefix + "VerifyComponentRouteMismatch";
|
||||
public const string VerifySeedClosureIncomplete = Prefix + "VerifySeedClosureIncomplete";
|
||||
|
|
@ -80,8 +75,6 @@ public static class McpErrorCodes
|
|||
public const string InvalidColumnLength = Prefix + "InvalidColumnLength";
|
||||
public const string InvalidDefaultValue = Prefix + "InvalidDefaultValue";
|
||||
public const string DuplicateColumn = Prefix + "DuplicateColumn";
|
||||
public const string TableColumnNotFound = Prefix + "TableColumnNotFound";
|
||||
public const string ForeignKeyTableNotInstalledFirst = Prefix + "ForeignKeyTableNotInstalledFirst";
|
||||
public const string SqlObjectInvalid = Prefix + "SqlObjectInvalid";
|
||||
public const string TableAlreadyExists = Prefix + "TableAlreadyExists";
|
||||
public const string TableProviderNotSupported = Prefix + "TableProviderNotSupported";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Aracin modelle konusarak cozulecek reddi: eksik ya da hatali girdi, durum kurali, eksik yetki.
|
||||
/// Istisna olarak atilmaz — bu durum aracin beklenen cevabidir; atilan istisna debugger'i durdurur
|
||||
/// ve SDK'da hata logu uretir. Istemciye hata sonucu (<c>IsError</c>) olarak gider, model mesaji
|
||||
/// okuyup girdiyi duzeltir ve tekrar dener.
|
||||
/// </summary>
|
||||
public sealed record McpProblem(string Message)
|
||||
{
|
||||
public static McpProblem From(IStringLocalizer localizer, string name, params object[] arguments)
|
||||
=> new(localizer[name, arguments]);
|
||||
}
|
||||
|
||||
/// <summary>Aracin degeri ya da <see cref="McpProblem"/>'i; ikisi birden olmaz.</summary>
|
||||
public readonly struct McpOutcome<T>
|
||||
{
|
||||
private McpOutcome(T? value, McpProblem? problem)
|
||||
{
|
||||
Value = value;
|
||||
Problem = problem;
|
||||
}
|
||||
|
||||
public T? Value { get; }
|
||||
|
||||
public McpProblem? Problem { get; }
|
||||
|
||||
public bool TryGetValue([MaybeNullWhen(false)] out T value, [NotNullWhen(false)] out McpProblem? problem)
|
||||
{
|
||||
value = Value!;
|
||||
problem = Problem;
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
public static implicit operator McpOutcome<T>(T value) => new(value, null);
|
||||
|
||||
public static implicit operator McpOutcome<T>(McpProblem problem) => new(default, problem);
|
||||
}
|
||||
|
||||
/// <summary>Yetki reddi mesajinda yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir.</summary>
|
||||
public static class McpPermissionText
|
||||
{
|
||||
public static string Describe(IStringLocalizer localizer, string permissionName)
|
||||
{
|
||||
var displayName = localizer[permissionName];
|
||||
|
||||
return displayName.ResourceNotFound || displayName.Value == permissionName
|
||||
? permissionName
|
||||
: $"{displayName.Value} ({permissionName})";
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,16 @@ namespace Sozsoft.Mcp;
|
|||
|
||||
/// <summary>
|
||||
/// MCP islemlerinin talep Timesheets satirina yazilan aciklamalari. Karsiliklari platformun
|
||||
/// <c>Sas.Languages.LanguageText/{CultureName}.json</c> dosyalarinda (her dilde bir satir) durur; satir Turkce yazilir.
|
||||
/// <c>App.Languages.LanguageText.json</c> dosyasinda EN + TR olarak durur; satir Turkce yazilir.
|
||||
/// </summary>
|
||||
public static class McpTimesheetTexts
|
||||
{
|
||||
private const string Prefix = "Sas.Mcp.Timesheet.";
|
||||
private const string Prefix = "App.Mcp.Timesheet.";
|
||||
|
||||
public const string Analysis = Prefix + "Analysis";
|
||||
public const string AppendNote = Prefix + "AppendNote";
|
||||
public const string AddSubTasks = Prefix + "AddSubTasks";
|
||||
public const string CreateTable = Prefix + "CreateTable";
|
||||
public const string SaveSqlObject = Prefix + "SaveSqlObject";
|
||||
public const string GenerateCrudEndpoints = Prefix + "GenerateCrudEndpoints";
|
||||
public const string DeployWizard = Prefix + "DeployWizard";
|
||||
public const string DeleteWizard = Prefix + "DeleteWizard";
|
||||
public const string SaveComponent = Prefix + "SaveComponent";
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ public static class McpToolNames
|
|||
public const string DeleteRow = "delete_row";
|
||||
public const string GetDevGuide = "get_dev_guide";
|
||||
public const string CreateTable = "create_table";
|
||||
public const string SaveSqlObject = "save_sql_object";
|
||||
public const string GenerateCrudEndpoints = "generate_crud_endpoints";
|
||||
public const string ListWizards = "list_wizards";
|
||||
public const string GetWizard = "get_wizard";
|
||||
public const string DeployWizard = "deploy_wizard";
|
||||
|
|
@ -57,8 +55,6 @@ public static class McpToolNames
|
|||
[
|
||||
GetDevGuide,
|
||||
CreateTable,
|
||||
SaveSqlObject,
|
||||
GenerateCrudEndpoints,
|
||||
ListWizards,
|
||||
GetWizard,
|
||||
DeployWizard,
|
||||
|
|
@ -123,8 +119,6 @@ public static class McpToolNames
|
|||
[UpdateRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[DeleteRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[CreateTable] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[SaveSqlObject] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[GenerateCrudEndpoints] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[DeployWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[DeleteWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[StartDevRequest] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
|
|
@ -143,7 +137,7 @@ public static class McpToolNames
|
|||
/// Arkadaki servisin <c>[Authorize]</c> attribute'u ile istedigi yetkiler. Attribute ile
|
||||
/// reddedilen cagri eksik yetkinin adini tasimaz; kullaniciya hangi yetkinin eksik oldugunu
|
||||
/// soyleyebilmek icin burada tutulur. Kodda adiyla istenen yetkiler (ekran C/R/U/D,
|
||||
/// <c>Wizards.Create/Update</c>, Custom Components, <c>Sas.DevRequests.Update</c>) reddi zaten
|
||||
/// <c>Wizards.Create/Update</c>, Custom Components, <c>App.DevRequests.Update</c>) reddi zaten
|
||||
/// adiyla bildirdigi icin buraya yazilmaz.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> ServicePermissions = new Dictionary<string, string[]>
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Authorization.Permissions;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
public static class PermissionCheckerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Verilen yetki adlarından kullanıcıya açık olanlar; tek toplu kontrol yapılır ve sonuç
|
||||
/// adla sorgulanabilen küme olarak döner. Araç listesi, ekran süzgeci ve ret mesajı aynı kümeyi okur.
|
||||
/// </summary>
|
||||
public static async Task<HashSet<string>> GetGrantedNamesAsync(
|
||||
this IPermissionChecker permissionChecker,
|
||||
IEnumerable<string> names)
|
||||
{
|
||||
var distinct = names.Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (distinct.Length == 0)
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var grants = await permissionChecker.IsGrantedAsync(distinct);
|
||||
|
||||
return grants.Result
|
||||
.Where(result => result.Value == PermissionGrantResult.Granted)
|
||||
.Select(result => result.Key)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,15 +7,16 @@ using Volo.Abp.DependencyInjection;
|
|||
namespace Sozsoft.Mcp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Bilinen MCP istemcilerini (<see cref="McpConsts.OAuthClientId"/>,
|
||||
/// <see cref="McpConsts.LocalOAuthClientId"/>) seed eder. Geri donus adresleri
|
||||
/// <c>OpenIddict:Applications:{ClientId}:RedirectUris</c> ayarindadir; yeni bir saglayici eklemek
|
||||
/// bu listeye callback eklemektir. Ayari olmayan istemci olusturulmaz.
|
||||
/// Bilinen MCP istemcisini (<see cref="McpConsts.OAuthClientId"/>) seed eder. Geri donus
|
||||
/// adresleri <c>OpenIddict:Applications:Platform_Mcp:RedirectUris</c> ayarindadir; yeni bir
|
||||
/// saglayici eklemek bu listeye callback eklemektir.
|
||||
/// </summary>
|
||||
public class McpOAuthClientDataSeedContributor(
|
||||
IConfiguration configuration,
|
||||
IMcpOAuthClientManager clientManager) : IDataSeedContributor, ITransientDependency
|
||||
{
|
||||
private const string RedirectUrisKey = "OpenIddict:Applications:Platform_Mcp:RedirectUris";
|
||||
|
||||
public async Task SeedAsync(DataSeedContext context)
|
||||
{
|
||||
// OpenIddict uygulamalari host kapsamindadir; kiraci seed turlarinda tekrarlanmaz.
|
||||
|
|
@ -24,15 +25,12 @@ public class McpOAuthClientDataSeedContributor(
|
|||
return;
|
||||
}
|
||||
|
||||
foreach (var clientId in new[] { McpConsts.OAuthClientId, McpConsts.LocalOAuthClientId })
|
||||
var redirectUris = configuration.GetSection(RedirectUrisKey).Get<string[]>();
|
||||
if (redirectUris is null || redirectUris.Length == 0)
|
||||
{
|
||||
var redirectUris = configuration.GetSection($"OpenIddict:Applications:{clientId}:RedirectUris").Get<string[]>();
|
||||
if (redirectUris is null || redirectUris.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await clientManager.EnsureKnownClientAsync(clientId, redirectUris);
|
||||
return;
|
||||
}
|
||||
|
||||
await clientManager.EnsureKnownClientAsync(redirectUris);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,17 @@ public class McpListFormAccessFilter(
|
|||
}
|
||||
}
|
||||
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(readPermissions.Values);
|
||||
var distinctPermissions = readPermissions.Values.Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (distinctPermissions.Length == 0)
|
||||
{
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var grants = await permissionChecker.IsGrantedAsync(distinctPermissions);
|
||||
var granted = grants.Result
|
||||
.Where(result => result.Value == PermissionGrantResult.Granted)
|
||||
.Select(result => result.Key)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return readPermissions
|
||||
.Where(entry => granted.Contains(entry.Value))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
|
@ -6,22 +6,18 @@ using System.Text.Json;
|
|||
using System.Threading.Tasks;
|
||||
using OpenIddict.Abstractions;
|
||||
using Sozsoft.Platform;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DistributedLocking;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
using Volo.Abp.OpenIddict.Applications;
|
||||
using Volo.Abp.Uow;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Bilinen istemci</b>: geri donus adresleri DbMigrator ayarinda sabit olan istemciler —
|
||||
/// <see cref="McpConsts.OAuthClientId"/> (Claude, ChatGPT; onay ekrani gostermez) ve
|
||||
/// <see cref="McpConsts.LocalOAuthClientId"/> (yerel mcp-remote proxy'si; acik onayli).</item>
|
||||
/// <item><b>Bilinen istemci</b> (<see cref="McpConsts.OAuthClientId"/>): geri donus adresleri
|
||||
/// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez.</item>
|
||||
/// <item><b>Dinamik istemci</b> (<see cref="McpConsts.DynamicClientIdPrefix"/>): baska bir MCP
|
||||
/// istemcisi (Cursor, VS Code, Claude Code, ...) kayit ucundan kendi uygulamasini alir. Kayit
|
||||
/// herkese acik oldugu icin bu istemciler <b>acik onay</b> ister: kullanici giris yaptiktan sonra
|
||||
|
|
@ -32,10 +28,10 @@ namespace Sozsoft.Mcp.Services;
|
|||
public interface IMcpOAuthClientManager
|
||||
{
|
||||
/// <summary>Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller.</summary>
|
||||
Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris);
|
||||
Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris);
|
||||
|
||||
/// <summary>
|
||||
/// Istemci kaydi. Adresler bir bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
|
||||
/// Istemci kaydi. Adresler bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
|
||||
/// sahip dinamik istemciyi dondurur; yoksa yenisini uretir.
|
||||
/// </summary>
|
||||
Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris);
|
||||
|
|
@ -44,33 +40,25 @@ public interface IMcpOAuthClientManager
|
|||
public class McpOAuthClientManager(
|
||||
IAbpApplicationManager applicationManager,
|
||||
IRepository<OpenIddictApplication, Guid> applicationRepository,
|
||||
ICurrentTenant currentTenant,
|
||||
IAbpDistributedLock distributedLock,
|
||||
IUnitOfWorkManager unitOfWorkManager)
|
||||
ICurrentTenant currentTenant)
|
||||
: DomainService, IMcpOAuthClientManager
|
||||
{
|
||||
private const int MaxClientNameLength = 100;
|
||||
private const string RegistrationLockName = "Mcp:OAuthClientRegistration";
|
||||
private static readonly TimeSpan RegistrationLockTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static readonly Dictionary<string, (string DisplayName, string ConsentType)> KnownClients = new(StringComparer.Ordinal)
|
||||
{
|
||||
[McpConsts.OAuthClientId] = ("MCP Connectors", OpenIddictConstants.ConsentTypes.Implicit),
|
||||
[McpConsts.LocalOAuthClientId] = ("MCP Local Proxy", OpenIddictConstants.ConsentTypes.Explicit)
|
||||
};
|
||||
|
||||
/// <summary>Tarayici disi calistirilabilir icerik tasiyan, geri donus adresi olamayacak semalar.</summary>
|
||||
private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"];
|
||||
|
||||
public async Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris)
|
||||
public async Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris)
|
||||
{
|
||||
var (displayName, consentType) = KnownClients[clientId];
|
||||
|
||||
// OpenIddict uygulamalari host kapsamindadir.
|
||||
using (currentTenant.Change(null))
|
||||
{
|
||||
var existing = await applicationManager.FindByClientIdAsync(clientId);
|
||||
var descriptor = BuildDescriptor(clientId, displayName, redirectUris, consentType);
|
||||
var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
|
||||
var descriptor = BuildDescriptor(
|
||||
McpConsts.OAuthClientId,
|
||||
"MCP Connectors",
|
||||
redirectUris,
|
||||
OpenIddictConstants.ConsentTypes.Implicit);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
|
|
@ -100,63 +88,42 @@ public class McpOAuthClientManager(
|
|||
|
||||
using (currentTenant.Change(null))
|
||||
{
|
||||
foreach (var knownClientId in KnownClients.Keys)
|
||||
var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
|
||||
if (known is not null)
|
||||
{
|
||||
var known = await applicationManager.FindByClientIdAsync(knownClientId);
|
||||
if (known is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var knownUris = await applicationManager.GetRedirectUrisAsync(known);
|
||||
if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri))))
|
||||
{
|
||||
return McpClientRegistration.Accepted(knownClientId);
|
||||
return McpClientRegistration.Accepted(McpConsts.OAuthClientId);
|
||||
}
|
||||
}
|
||||
|
||||
// Masaustu istemcisi ayni anda birden cok proxy sureci baslatir ve hepsi ayni adreslerle
|
||||
// kayit olur. Kontrol ile ekleme kilitsiz ve istegin UoW'sinde kalirsa hicbiri digerinin
|
||||
// kaydini goremez, ayni adres kumesi icin birden cok uygulama acilir. Kilit altinda
|
||||
// calisip kendi UoW'sinde commit eden kayit, sonraki istegin onu bulmasini saglar.
|
||||
await using var handle = await distributedLock.TryAcquireAsync(RegistrationLockName, RegistrationLockTimeout)
|
||||
?? throw new AbpException($"Could not acquire '{RegistrationLockName}' lock.");
|
||||
var dynamicClients = await GetDynamicClientsAsync();
|
||||
|
||||
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
|
||||
var registration = await RegisterDynamicClientAsync(clientName, redirectUris);
|
||||
await uow.CompleteAsync();
|
||||
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
|
||||
// baglanir, tablo buyumez.
|
||||
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
|
||||
if (match.ClientId is not null)
|
||||
{
|
||||
return McpClientRegistration.Accepted(match.ClientId);
|
||||
}
|
||||
|
||||
return registration;
|
||||
if (dynamicClients.Count >= McpConsts.MaxDynamicClients)
|
||||
{
|
||||
return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N");
|
||||
await applicationManager.CreateAsync(BuildDescriptor(
|
||||
clientId,
|
||||
DisplayNameOf(clientName, redirectUris.First()),
|
||||
redirectUris,
|
||||
OpenIddictConstants.ConsentTypes.Explicit));
|
||||
|
||||
return McpClientRegistration.Accepted(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<McpClientRegistration> RegisterDynamicClientAsync(string? clientName, IReadOnlyCollection<string> redirectUris)
|
||||
{
|
||||
var dynamicClients = await GetDynamicClientsAsync();
|
||||
|
||||
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
|
||||
// baglanir, tablo buyumez.
|
||||
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
|
||||
if (match.ClientId is not null)
|
||||
{
|
||||
return McpClientRegistration.Accepted(match.ClientId);
|
||||
}
|
||||
|
||||
if (dynamicClients.Count >= McpConsts.MaxDynamicClients)
|
||||
{
|
||||
return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N");
|
||||
await applicationManager.CreateAsync(BuildDescriptor(
|
||||
clientId,
|
||||
DisplayNameOf(clientName, redirectUris.First()),
|
||||
redirectUris,
|
||||
OpenIddictConstants.ConsentTypes.Explicit));
|
||||
|
||||
return McpClientRegistration.Accepted(clientId);
|
||||
}
|
||||
|
||||
private async Task<List<(string? ClientId, IReadOnlyList<string> RedirectUris)>> GetDynamicClientsAsync()
|
||||
{
|
||||
var query = await applicationRepository.GetQueryableAsync();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using System.Linq;
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
|
@ -44,6 +43,11 @@ public class McpSchemaDictionary(
|
|||
ICurrentTenant currentTenant)
|
||||
: DomainService, IMcpSchemaDictionary
|
||||
{
|
||||
private static readonly JsonSerializerOptions DictionaryJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly CultureInfo TurkishCulture = CultureInfo.GetCultureInfo("tr-TR");
|
||||
|
||||
public string ResolveTenantName()
|
||||
|
|
@ -77,7 +81,7 @@ public class McpSchemaDictionary(
|
|||
return [];
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, PlatformJsonOptions.CaseInsensitive);
|
||||
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, DictionaryJsonOptions);
|
||||
|
||||
return document?.ListForms ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
|
@ -16,11 +16,14 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// </summary>
|
||||
public interface IMcpSqlGuard
|
||||
{
|
||||
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur.</summary>
|
||||
McpProblem? CheckReadOnlyStatement(string sql);
|
||||
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar.</summary>
|
||||
void EnsureReadOnlyStatement(string sql);
|
||||
|
||||
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
|
||||
McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
|
||||
string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
|
||||
|
||||
/// <summary>Saglayicinin varsayilan semasi.</summary>
|
||||
string DefaultSchemaName(DataSourceTypeEnum dataSourceType);
|
||||
|
||||
/// <summary>
|
||||
/// Satir tavanini sorgunun kendisine yazar: SQL Server'da <c>TOP (n)</c>, PostgreSQL'de
|
||||
|
|
@ -32,11 +35,16 @@ public interface IMcpSqlGuard
|
|||
/// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in
|
||||
/// bekledigi bicime cevirir.
|
||||
/// </summary>
|
||||
McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
|
||||
Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
|
||||
}
|
||||
|
||||
public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard
|
||||
{
|
||||
/// <summary>Tek parcali SQL tanimlayicisi; sema ve nesne adlari bu kalibi gecmek zorundadir.</summary>
|
||||
private static readonly Regex SafeIdentifierRegex = new(
|
||||
@"^[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex SafeParameterNameRegex = new(
|
||||
@"^@?[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
|
@ -70,18 +78,18 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
@"\bLIMIT\s+\d+",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public McpProblem? CheckReadOnlyStatement(string sql)
|
||||
public void EnsureReadOnlyStatement(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.EmptyQuery);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.EmptyQuery]);
|
||||
}
|
||||
|
||||
var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''");
|
||||
|
||||
if (stripped.Contains(';', StringComparison.Ordinal))
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]);
|
||||
}
|
||||
|
||||
var trimmed = stripped.TrimStart();
|
||||
|
|
@ -90,29 +98,30 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
|
||||
if (!startsWithRead)
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]);
|
||||
}
|
||||
|
||||
var forbidden = ForbiddenStatementRegex.Match(stripped);
|
||||
|
||||
return forbidden.Success
|
||||
? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
|
||||
: null;
|
||||
}
|
||||
|
||||
public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
|
||||
{
|
||||
foreach (var name in (string?[])[schemaName, objectName])
|
||||
if (forbidden.Success)
|
||||
{
|
||||
if (!SqlIdentifier.IsSafe(name))
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
|
||||
}
|
||||
throw new UserFriendlyException(
|
||||
localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]);
|
||||
}
|
||||
|
||||
return SqlIdentifier.QuoteQualified(schemaName, objectName, dataSourceType);
|
||||
}
|
||||
|
||||
public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
|
||||
{
|
||||
EnsureSafeIdentifier(schemaName);
|
||||
EnsureSafeIdentifier(objectName);
|
||||
|
||||
return dataSourceType == DataSourceTypeEnum.Postgresql
|
||||
? $"\"{schemaName}\".\"{objectName}\""
|
||||
: $"[{schemaName}].[{objectName}]";
|
||||
}
|
||||
|
||||
public string DefaultSchemaName(DataSourceTypeEnum dataSourceType)
|
||||
=> dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo";
|
||||
|
||||
public string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType)
|
||||
{
|
||||
if (dataSourceType == DataSourceTypeEnum.Postgresql)
|
||||
|
|
@ -135,7 +144,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
: sql;
|
||||
}
|
||||
|
||||
public McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
|
||||
public Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
|
||||
{
|
||||
var normalized = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
if (parameters is null)
|
||||
|
|
@ -147,7 +156,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
{
|
||||
if (key is null || !SafeParameterNameRegex.IsMatch(key))
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]);
|
||||
}
|
||||
|
||||
normalized[key.TrimStart('@')] = value!;
|
||||
|
|
@ -155,4 +164,12 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void EnsureSafeIdentifier(string identifier)
|
||||
{
|
||||
if (!SafeIdentifierRegex.IsMatch(identifier ?? string.Empty))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier ?? string.Empty]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ekranin veri kaynagi olan view, procedure ve fonksiyon scriptlerini SQL Query Manager'in yazdigi
|
||||
/// yere (<c>sql/object/{Ad}.sql</c>) ve PostgreSQL esine (<c>postgres/object/{Ad}.sql</c>) yazar.
|
||||
/// Govde diyalektler arasinda guvenle cevrilemedigi icin (<see cref="PostgreSqlTableScript"/>) iki
|
||||
/// script de istemciden gelir; biri olmadan yazilmaz.
|
||||
/// <para>
|
||||
/// Veritabaninda calistirilmaz: nesneyi, tablo gibi DB Migrate kurar (<c>SqlDataSeeder</c>). Nesnenin
|
||||
/// adi SQL Server scriptinin basligindan okunur; basliklar tekrar calismaya dayanikli bicime cevrilir
|
||||
/// (<c>CREATE OR ALTER</c> / <c>CREATE OR REPLACE</c>), ayni adla tekrar yazmak scripti gunceller.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IMcpSqlObjectWriter
|
||||
{
|
||||
McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript);
|
||||
}
|
||||
|
||||
public partial class McpSqlObjectWriter(
|
||||
IConfiguration configuration,
|
||||
IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlObjectWriter
|
||||
{
|
||||
private const string SqlServerProvider = "SQL Server";
|
||||
private const string PostgreSqlProvider = "PostgreSQL";
|
||||
|
||||
public McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript)
|
||||
{
|
||||
var sqlServer = Canonicalize(sqlServerScript, SqlServerHeaderRegex(), "CREATE OR ALTER");
|
||||
if (sqlServer is null)
|
||||
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, SqlServerProvider);
|
||||
|
||||
var postgreSql = Canonicalize(postgreSqlScript, PostgreSqlHeaderRegex(), "CREATE OR REPLACE");
|
||||
if (postgreSql is null || !string.Equals(sqlServer.Name, postgreSql.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, PostgreSqlProvider);
|
||||
|
||||
// Tablo scriptinin yerine view yazilirsa migrate tabloyu bir daha kuramazdi.
|
||||
if (PostgreSqlTableScript.ReadSeedColumns(configuration, sqlServer.Name).Count > 0)
|
||||
return McpProblem.From(localizer, McpErrorCodes.TableAlreadyExists, sqlServer.Name);
|
||||
|
||||
var fileName = $"{sqlServer.Name}.sql";
|
||||
var sqlServerPath = Write(SeedPathResolver.SqlFolder, fileName, sqlServer.Script);
|
||||
var postgreSqlPath = Write(SeedPathResolver.PostgresFolder, fileName, postgreSql.Script);
|
||||
|
||||
return new McpSavedSqlObject(sqlServer.Name, sqlServer.Kind, sqlServerPath, postgreSqlPath);
|
||||
}
|
||||
|
||||
/// <summary>Scripti saglayicinin <c>object</c> klasorune yazar; seed kokune goreli yolu dondurur.</summary>
|
||||
private string Write(string providerFolder, string fileName, string script)
|
||||
{
|
||||
var folder = SeedPathResolver.GetPath(configuration, providerFolder, SeedPathResolver.ObjectFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, fileName), script);
|
||||
|
||||
return $"{providerFolder}/{SeedPathResolver.ObjectFolder}/{fileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script tek bir nesne tanimlamalidir; basligi tekrar calismaya dayanikli bicime cevrilir.
|
||||
/// Yorumlar yalnizca aramada yok sayilir, dosyada kalir. Tek nesne tanimlamayan script icin null.
|
||||
/// </summary>
|
||||
private static CanonicalScript? Canonicalize(string script, Regex headerRegex, string createClause)
|
||||
{
|
||||
var headers = string.IsNullOrWhiteSpace(script) ? null : headerRegex.Matches(CommentRegex().Replace(script, " "));
|
||||
if (headers is not { Count: 1 })
|
||||
return null;
|
||||
|
||||
var name = headers[0].Groups["name"].Value;
|
||||
var kind = headers[0].Groups["kind"].Value.ToUpperInvariant() switch
|
||||
{
|
||||
"PROC" => "PROCEDURE",
|
||||
var value => value
|
||||
};
|
||||
|
||||
// Yorumdaki ornek baslik degismesin diye yalnizca nesnenin adini tasiyan ilk baslik cevrilir.
|
||||
var replaced = false;
|
||||
var canonical = headerRegex.Replace(script.Trim(), m =>
|
||||
{
|
||||
if (replaced || !string.Equals(m.Groups["name"].Value, name, StringComparison.OrdinalIgnoreCase))
|
||||
return m.Value;
|
||||
|
||||
replaced = true;
|
||||
return $"{createClause} {kind} {m.Groups["qualified"].Value}";
|
||||
});
|
||||
|
||||
return new CanonicalScript(canonical + Environment.NewLine, name, kind);
|
||||
}
|
||||
|
||||
private sealed record CanonicalScript(string Script, string Name, string Kind);
|
||||
|
||||
[GeneratedRegex(@"/\*.*?\*/|--[^\r\n]*", RegexOptions.Singleline)]
|
||||
private static partial Regex CommentRegex();
|
||||
|
||||
[GeneratedRegex(@"\b(?:CREATE\s+OR\s+ALTER|CREATE|ALTER)\s+(?<kind>VIEW|PROCEDURE|PROC|FUNCTION)\s+(?<qualified>(?:\[?dbo\]?\s*\.\s*)?\[?(?<name>[A-Za-z_][A-Za-z0-9_]*)\]?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SqlServerHeaderRegex();
|
||||
|
||||
[GeneratedRegex(@"\bCREATE\s+(?:OR\s+REPLACE\s+)?(?<kind>VIEW|PROCEDURE|FUNCTION)\s+(?<qualified>(?:""?public""?\s*\.\s*)?""?(?<name>[A-Za-z_][A-Za-z0-9_]*)""?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PostgreSqlHeaderRegex();
|
||||
}
|
||||
|
||||
/// <param name="Kind"><c>VIEW</c>, <c>PROCEDURE</c> ya da <c>FUNCTION</c>.</param>
|
||||
/// <param name="SqlServerPath">Seed kokune gore goreli yol.</param>
|
||||
/// <param name="PostgreSqlPath">Seed kokune gore goreli yol.</param>
|
||||
public sealed record McpSavedSqlObject(string Name, string Kind, string SqlServerPath, string PostgreSqlPath);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
|
@ -6,7 +6,6 @@ using System.Text.RegularExpressions;
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.Localization;
|
||||
|
|
@ -18,13 +17,9 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// <summary>
|
||||
/// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script
|
||||
/// tasarimcinin <c>generateCreateTableSql</c> ciktisiyla ayni bicimdedir ve tasarimcinin deploy
|
||||
/// sonrasi yazdigi yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo
|
||||
/// sonrasi yazdigi yere (<c>{kapsam}/sql/object/{Tablo}.sql</c>) yazilir; boylece tablo
|
||||
/// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir.
|
||||
/// <para>
|
||||
/// Veritabaninda calistirilmaz: tabloyu, wizard deploy'unun ekranini kurdugu gibi kullanicinin
|
||||
/// calistirdigi DB Migrate kurar (<c>SqlDataSeeder</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Yalnizca olusturma yapar. Var olan tabloya dokunulmaz — kolon ekleme/degistirme tasarimcinin
|
||||
/// isidir ve sema degisikligi kurallarina (kayipsiz ekleme, degistirme/dusurme yok) tabidir.
|
||||
/// Kolon tipi ve varsayilan deger serbest metin degil, beyaz listeden gecer; istemci SQL yazamaz.
|
||||
|
|
@ -32,7 +27,7 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// </summary>
|
||||
public interface IMcpTableCreator
|
||||
{
|
||||
Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition);
|
||||
Task<McpCreatedTable> CreateAsync(McpTableDefinition definition);
|
||||
}
|
||||
|
||||
public class McpTableCreator(
|
||||
|
|
@ -44,6 +39,14 @@ public class McpTableCreator(
|
|||
private const int DefaultTextLength = 100;
|
||||
private const int MaxTextLength = 4000;
|
||||
|
||||
private static readonly Regex SafeIdentifierRegex = new(
|
||||
@"^[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
|
||||
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Istemcinin SQL bicimiyle yazdigi tip: <c>nvarchar(50)</c>, <c>nvarchar (MAX)</c>,
|
||||
/// <c>decimal(18, 4)</c>. Boyut tipten ayrilir; tip adi yine beyaz listeden gecer.
|
||||
|
|
@ -55,10 +58,6 @@ public class McpTableCreator(
|
|||
/// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary>
|
||||
private const string DecimalSize = "18,4";
|
||||
|
||||
/// <summary>
|
||||
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
|
||||
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
|
||||
/// </summary>
|
||||
private static readonly Regex SafeDefaultRegex = new(
|
||||
@"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
|
@ -71,16 +70,6 @@ public class McpTableCreator(
|
|||
];
|
||||
|
||||
// Sablon kolonlari tasarimcinin FULL_AUDIT_COLUMNS / TENANT_COLUMN tanimlariyla aynidir.
|
||||
/// <summary>Tasarimcinin FK silme davranislari; anahtar istemcinin yazdigi, deger SQL karsiligidir.</summary>
|
||||
private static readonly Dictionary<string, string> ReferentialActions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["NoAction"] = "NO ACTION",
|
||||
["Cascade"] = "CASCADE",
|
||||
["SetNull"] = "SET NULL",
|
||||
};
|
||||
|
||||
private const string DefaultReferentialAction = "NoAction";
|
||||
|
||||
private static readonly McpTableColumn IdColumn = new("Id", "uniqueidentifier", null, false, "NEWID()");
|
||||
|
||||
private static readonly McpTableColumn TenantColumn = new("TenantId", "uniqueidentifier", null, true, null);
|
||||
|
|
@ -96,87 +85,59 @@ public class McpTableCreator(
|
|||
new("DeleterId", "uniqueidentifier", null, true, null)
|
||||
];
|
||||
|
||||
public async Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition)
|
||||
public async Task<McpCreatedTable> CreateAsync(McpTableDefinition definition)
|
||||
{
|
||||
Check.NotNull(definition, nameof(definition));
|
||||
|
||||
var tableName = (definition.TableName ?? string.Empty).Trim();
|
||||
if (CheckIdentifier(tableName) is { } invalidName)
|
||||
{
|
||||
return invalidName;
|
||||
}
|
||||
EnsureSafeIdentifier(tableName);
|
||||
|
||||
if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) ||
|
||||
!BuildIndexes(tableName, definition.Indexes, columns).TryGetValue(out var indexes, out problem) ||
|
||||
!BuildForeignKeys(definition.ForeignKeys, columns).TryGetValue(out var foreignKeys, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
var columns = BuildColumns(definition);
|
||||
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
|
||||
|
||||
async Task<bool> TableExistsAsync(string name)
|
||||
if (context.DataSourceType != DataSourceTypeEnum.Mssql)
|
||||
{
|
||||
var existing = await context.Repository.ExecuteScalarAsync<object?>(
|
||||
isPostgreSql ? "SELECT to_regclass(@name)::text" : "SELECT OBJECT_ID(@name, 'U')",
|
||||
context.ConnectionString,
|
||||
new Dictionary<string, object> { ["name"] = isPostgreSql ? $"public.\"{name}\"" : $"{SchemaName}.{name}" });
|
||||
|
||||
return existing is not null and not DBNull;
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableProviderNotSupported, context.DataSourceType.ToString()]);
|
||||
}
|
||||
|
||||
// Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun
|
||||
// scriptinin ustune yazmaz.
|
||||
if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0)
|
||||
var existing = await context.Repository.ExecuteScalarAsync<int?>(
|
||||
"SELECT OBJECT_ID(@name, 'U')",
|
||||
context.ConnectionString,
|
||||
new Dictionary<string, object> { ["name"] = $"{SchemaName}.{tableName}" });
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return Problem(McpErrorCodes.TableAlreadyExists, tableName);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]);
|
||||
}
|
||||
|
||||
foreach (var foreignKey in foreignKeys)
|
||||
{
|
||||
if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled)
|
||||
{
|
||||
return notInstalled;
|
||||
}
|
||||
}
|
||||
var script = BuildScript(tableName, columns);
|
||||
|
||||
// Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir.
|
||||
var script = BuildScript(tableName, columns, indexes, foreignKeys);
|
||||
if (!PostgreSqlTableScript.TryConvert(script, out _))
|
||||
{
|
||||
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
|
||||
}
|
||||
// GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir.
|
||||
await context.Repository.ExecuteAsync(
|
||||
string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")),
|
||||
context.ConnectionString);
|
||||
|
||||
// Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin
|
||||
// calistirdigi DB Migrate'tedir (SqlDataSeeder).
|
||||
var (seedFilePath, _) = PostgreSqlTableScript.WriteSeedFiles(configuration, tableName, script);
|
||||
var seedFilePath = SaveSeedScript(tableName, script);
|
||||
|
||||
return new McpCreatedTable(tableName, script, seedFilePath);
|
||||
}
|
||||
|
||||
private McpOutcome<List<McpTableColumn>> BuildColumns(McpTableDefinition definition)
|
||||
private List<McpTableColumn> BuildColumns(McpTableDefinition definition)
|
||||
{
|
||||
if (definition.Columns.Count == 0)
|
||||
{
|
||||
return Problem(McpErrorCodes.TableColumnsRequired);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnsRequired]);
|
||||
}
|
||||
|
||||
var userColumns = definition.Columns.Select(NormalizeColumn).ToList();
|
||||
|
||||
var columns = new List<McpTableColumn> { IdColumn };
|
||||
if (definition.MultiTenant)
|
||||
{
|
||||
columns.Add(TenantColumn);
|
||||
}
|
||||
|
||||
foreach (var column in definition.Columns)
|
||||
{
|
||||
if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
columns.Add(normalized);
|
||||
}
|
||||
columns.AddRange(userColumns);
|
||||
|
||||
if (definition.FullAudited)
|
||||
{
|
||||
|
|
@ -187,121 +148,38 @@ public class McpTableCreator(
|
|||
.GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
|
||||
return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key);
|
||||
}
|
||||
|
||||
/// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
|
||||
private McpOutcome<List<McpTableIndex>> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
|
||||
{
|
||||
var result = new List<McpTableIndex>();
|
||||
foreach (var index in indexes)
|
||||
if (duplicate is not null)
|
||||
{
|
||||
var indexColumns = new List<string>();
|
||||
foreach (var column in index.Columns)
|
||||
{
|
||||
if (!ResolveColumn(column, columns).TryGetValue(out var resolved, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (!indexColumns.Contains(resolved))
|
||||
indexColumns.Add(resolved);
|
||||
}
|
||||
|
||||
var name = $"{(index.Unique ? "UQ" : "IX")}_{tableName}_{string.Join("_", indexColumns)}";
|
||||
|
||||
// Kolonsuz ya da ayni kolonlarla tekrar eden index uretilecek bir sey tasimaz.
|
||||
if (indexColumns.Count > 0 && !result.Exists(existing => string.Equals(existing.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||
result.Add(new McpTableIndex(indexColumns, index.Unique, name));
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]);
|
||||
}
|
||||
|
||||
return result;
|
||||
return columns;
|
||||
}
|
||||
|
||||
private McpOutcome<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
|
||||
{
|
||||
var result = new List<McpTableForeignKey>();
|
||||
foreach (var foreignKey in foreignKeys)
|
||||
{
|
||||
if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim();
|
||||
var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim();
|
||||
if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
// Tanimadigi davranis (Restrict gibi) SQL Server'in varsayilanina, NO ACTION'a duser.
|
||||
var action = ReferentialActions.Keys.FirstOrDefault(key => string.Equals(key, foreignKey.OnDelete?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultReferentialAction;
|
||||
|
||||
// Bir kolonun tek FK'si olur; ayni kolon icin tekrar eden tanimin ilki gecerlidir.
|
||||
if (!result.Exists(existing => string.Equals(existing.Column, column, StringComparison.OrdinalIgnoreCase)))
|
||||
result.Add(new McpTableForeignKey(column, referencedTable, referencedColumn, action));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed scriptleri DB Migrate'te dosya adi sirasiyla calisir (<c>SqlDataSeeder</c>). FK'nin
|
||||
/// gosterdigi tablo bir seed scriptiyle kuruluyorsa o dosya bu tablonunkinden once gelmelidir;
|
||||
/// scripti yoksa (EF ile kurulan platform tablosu) veritabaninda bulunmalidir. Aksi halde
|
||||
/// sifirdan kurulumda migrate FK satirinda duserdi.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func<string, Task<bool>> tableExistsAsync)
|
||||
{
|
||||
var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0;
|
||||
var installed = hasScript
|
||||
? Comparer<string>.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0
|
||||
: await tableExistsAsync(foreignKey.ReferencedTable);
|
||||
|
||||
return installed
|
||||
? null
|
||||
: Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName);
|
||||
}
|
||||
|
||||
private McpOutcome<string> ResolveColumn(string? name, List<McpTableColumn> columns)
|
||||
{
|
||||
var match = columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return match is null ? Problem(McpErrorCodes.TableColumnNotFound, name ?? string.Empty) : match.Name;
|
||||
}
|
||||
|
||||
private McpOutcome<McpTableColumn> NormalizeColumn(McpTableColumn column)
|
||||
private McpTableColumn NormalizeColumn(McpTableColumn column)
|
||||
{
|
||||
var name = (column.Name ?? string.Empty).Trim();
|
||||
if (CheckIdentifier(name) is { } invalidName)
|
||||
{
|
||||
return invalidName;
|
||||
}
|
||||
EnsureSafeIdentifier(name);
|
||||
|
||||
if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
var (type, sizedLength) = ResolveType(column.Type);
|
||||
|
||||
int? maxLength = null;
|
||||
if (resolved.Type == "nvarchar")
|
||||
if (type == "nvarchar")
|
||||
{
|
||||
maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength;
|
||||
maxLength = column.MaxLength ?? sizedLength ?? DefaultTextLength;
|
||||
if (maxLength is < 1 or > MaxTextLength)
|
||||
{
|
||||
return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidColumnLength, name, MaxTextLength]);
|
||||
}
|
||||
}
|
||||
|
||||
var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim();
|
||||
if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue))
|
||||
{
|
||||
return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue);
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]);
|
||||
}
|
||||
|
||||
return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue);
|
||||
return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -309,7 +187,7 @@ public class McpTableCreator(
|
|||
/// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir —
|
||||
/// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi.
|
||||
/// </summary>
|
||||
private McpOutcome<ResolvedType> ResolveType(string? rawType)
|
||||
private (string Type, int? MaxLength) ResolveType(string? rawType)
|
||||
{
|
||||
var trimmed = rawType?.Trim() ?? string.Empty;
|
||||
var name = trimmed;
|
||||
|
|
@ -339,16 +217,15 @@ public class McpTableCreator(
|
|||
}
|
||||
}
|
||||
|
||||
var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase));
|
||||
var type = SupportedTypes.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new UserFriendlyException(
|
||||
localizer[McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)]);
|
||||
|
||||
return type is null
|
||||
? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes))
|
||||
: new ResolvedType(type, maxLength);
|
||||
return (type, maxLength);
|
||||
}
|
||||
|
||||
private sealed record ResolvedType(string Type, int? MaxLength);
|
||||
|
||||
private static string BuildScript(string tableName, List<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
|
||||
private static string BuildScript(string tableName, List<McpTableColumn> columns)
|
||||
{
|
||||
var fullTableName = $"[{SchemaName}].[{tableName}]";
|
||||
|
||||
|
|
@ -369,42 +246,6 @@ public class McpTableCreator(
|
|||
lines.Add("END");
|
||||
lines.Add("GO");
|
||||
|
||||
// Tasarimcinin unique/index/FK deyimleri; her biri varlik kontroluyle yazilir ki migrate
|
||||
// scripti yeniden calistirdiginda dusmesin.
|
||||
foreach (var index in indexes)
|
||||
{
|
||||
var columnList = string.Join(", ", index.Columns.Select(column => $"[{column}] ASC"));
|
||||
lines.Add(string.Empty);
|
||||
if (index.Unique)
|
||||
{
|
||||
lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{index.Name}]', 'UQ') IS NULL");
|
||||
lines.Add("BEGIN");
|
||||
lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{index.Name}] UNIQUE NONCLUSTERED ({columnList});");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"IF INDEXPROPERTY(OBJECT_ID(N'{fullTableName}'), '{index.Name}', 'IndexID') IS NULL");
|
||||
lines.Add("BEGIN");
|
||||
lines.Add($" CREATE INDEX [{index.Name}] ON {fullTableName} ({columnList});");
|
||||
}
|
||||
|
||||
lines.Add("END");
|
||||
lines.Add("GO");
|
||||
}
|
||||
|
||||
foreach (var foreignKey in foreignKeys)
|
||||
{
|
||||
var name = $"FK_{tableName}_{foreignKey.Column}";
|
||||
lines.Add(string.Empty);
|
||||
lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{name}]', 'F') IS NULL");
|
||||
lines.Add("BEGIN");
|
||||
lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{name}] FOREIGN KEY ([{foreignKey.Column}]) " +
|
||||
$"REFERENCES [{SchemaName}].[{foreignKey.ReferencedTable}] ([{foreignKey.ReferencedColumn}]) " +
|
||||
$"ON DELETE {ReferentialActions[foreignKey.OnDelete!]} ON UPDATE NO ACTION;");
|
||||
lines.Add("END");
|
||||
lines.Add("GO");
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
|
|
@ -423,30 +264,42 @@ public class McpTableCreator(
|
|||
return $" [{column.Name}] {type} {nullPart}{defaultPart}";
|
||||
}
|
||||
|
||||
private McpProblem? CheckIdentifier(string identifier)
|
||||
=> SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier);
|
||||
/// <summary>
|
||||
/// Tasarimcinin <c>SaveTableScriptAsync</c> ile yazdigi yol: kapsam istegin kiracisini izler.
|
||||
/// Dosyasiz tablo veritabani sifirlandiginda geri gelmezdi; yazilamazsa islem hata verir.
|
||||
/// </summary>
|
||||
private string SaveSeedScript(string tableName, string script)
|
||||
{
|
||||
var outputPath = SeedPathResolver.GetScopePath(
|
||||
configuration,
|
||||
CurrentTenant.Id,
|
||||
SeedPathResolver.GetProviderFolderName(isPostgreSql: false),
|
||||
SeedPathResolver.ObjectFolder);
|
||||
|
||||
private McpProblem Problem(string name, params object[] arguments)
|
||||
=> McpProblem.From(localizer, name, arguments);
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var filePath = Path.Combine(outputPath, $"{tableName}.sql");
|
||||
File.WriteAllText(filePath, script);
|
||||
|
||||
return $"{SeedPathResolver.GetScopeFolderName(CurrentTenant.Id)}/{SeedPathResolver.SqlFolder}/{SeedPathResolver.ObjectFolder}/{tableName}.sql";
|
||||
}
|
||||
|
||||
private void EnsureSafeIdentifier(string identifier)
|
||||
{
|
||||
if (!SafeIdentifierRegex.IsMatch(identifier))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="FullAudited">Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi.</param>
|
||||
/// <param name="MultiTenant"><c>TenantId</c> kolonu eklensin mi.</param>
|
||||
/// <param name="Indexes">Index ve unique kisitlari; ad sunucuda uretilir (<c>IX_/UQ_{Tablo}_{Kolonlar}</c>).</param>
|
||||
/// <param name="ForeignKeys">FK'ler; ad <c>FK_{Tablo}_{Kolon}</c>.</param>
|
||||
public sealed record McpTableDefinition(
|
||||
string TableName,
|
||||
IReadOnlyList<McpTableColumn> Columns,
|
||||
bool FullAudited,
|
||||
bool MultiTenant,
|
||||
IReadOnlyList<McpTableIndex> Indexes,
|
||||
IReadOnlyList<McpTableForeignKey> ForeignKeys);
|
||||
|
||||
public sealed record McpTableIndex(IReadOnlyList<string> Columns, bool Unique, string? Name = null);
|
||||
|
||||
/// <param name="ReferencedColumn">Bos ise <c>Id</c>.</param>
|
||||
/// <param name="OnDelete"><c>NoAction</c> (varsayilan), <c>Cascade</c> ya da <c>SetNull</c>.</param>
|
||||
public sealed record McpTableForeignKey(string Column, string ReferencedTable, string? ReferencedColumn, string? OnDelete);
|
||||
bool MultiTenant);
|
||||
|
||||
/// <param name="MaxLength">Yalnizca <c>nvarchar</c> icin; bos ise tasarimcinin varsayilani (100).</param>
|
||||
public sealed record McpTableColumn(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Tools;
|
||||
using Volo.Abp.AspNetCore.Mvc;
|
||||
using Volo.Abp.Modularity;
|
||||
|
|
@ -75,27 +72,23 @@ public class McpHttpApiModule : AbpModule
|
|||
}
|
||||
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
|
||||
{
|
||||
throw new McpException(await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null), ex);
|
||||
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// MCP'nin kendi reddi istisna degildir (McpProblem). Arkadaki platform servisinin yetki
|
||||
// reddi ve is kurali hatasi ise istisnadir; firlatilirsa SDK onu yigin izli ERR olarak
|
||||
// loglar. Hata sonucu (IsError) olarak donulur; model nedeni okuyup devam eder.
|
||||
// SDK arac hatasinin mesajini yalnizca McpException ise iletir; yetki reddi ve is
|
||||
// kurali hatalari burada cevrilir ki istemci nedenini (eksik yetkiyi) gorsun.
|
||||
filters.AddCallToolFilter(next => async (requestContext, cancellationToken) =>
|
||||
{
|
||||
McpToolArgumentNormalizer.Normalize(requestContext.Params, requestContext.MatchedPrimitive as McpServerTool);
|
||||
|
||||
try
|
||||
{
|
||||
return await next(requestContext, cancellationToken);
|
||||
}
|
||||
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
|
||||
{
|
||||
var message = await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
|
||||
return McpToolResult.Error(message);
|
||||
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ internal static class McpServerInfoDefaults
|
|||
"then produce them with deploy_wizard and save_component; the guide's file-writing steps map to these tools. " +
|
||||
"Agree on screen type, data source, module and permissions with the user before deploying. " +
|
||||
"Analyzing development requests (Backlog -> Analysis) follows get_dev_guide section 0.12: read with get_dev_request, " +
|
||||
"write the analysis with save_dev_request_analysis, produce nothing and never touch Description; pass productionReady true only when the analysis is ready for production without conditions (the request moves to Ready to Start), otherwise it stays in Analysis. " +
|
||||
"Development requests (Sas.DevRequests) follow get_dev_guide section 0.11: claim each one with " +
|
||||
"write the analysis with save_dev_request_analysis, produce nothing and never touch Description; the user moves an approved analysis to Ready to Start. " +
|
||||
"Development requests (App.DevRequests) follow get_dev_guide section 0.11: claim each one with " +
|
||||
"start_dev_request, which only accepts requests in Ready to Start; images pasted into the request arrive as image content and are part of the request (layout and demo rows). " +
|
||||
"A request (Description) and its approved Analysis are its own brief: do not ask questions and do not wait for DB Migrate; deploy (demo rows go in deploy_wizard seedRows), complete the module document with get_module_doc / save_module_doc (section 0.13), then move the request to Testing and finish. " +
|
||||
"When a tool reports a missing permission, tell the user which permission is missing and stop; " +
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Model metin parametresine tirnaksiz deger (<c>section: 4.2</c>) ya da JSON metni yerine dogrudan
|
||||
/// dizi/nesne (<c>filter: ["Status","=","Open"]</c>) gonderebilir. SDK her parametreyi ayri ayri
|
||||
/// <c>string</c> olarak bagladigi icin bu deger <see cref="JsonException"/> ("Cannot get the value of a
|
||||
/// token type 'Number' / 'StartArray' as a string") ile duser. Deger cagri araca ulasmadan, aracin kendi
|
||||
/// semasina bakilarak ham JSON metnine cevrilir — JSON bekleyen parametre ayni metni alir; string icin
|
||||
/// ozel converter kullanilmaz, cunku arac semasindaki <c>"type": "string"</c> bilgisini siler.
|
||||
/// </summary>
|
||||
internal static class McpToolArgumentNormalizer
|
||||
{
|
||||
public static void Normalize(CallToolRequestParams? parameters, McpServerTool? tool)
|
||||
{
|
||||
if (parameters?.Arguments is not { Count: > 0 } arguments
|
||||
|| tool is null
|
||||
|| !tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties)
|
||||
|| properties.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, JsonElement>? normalized = null;
|
||||
|
||||
foreach (var (name, value) in arguments)
|
||||
{
|
||||
if (value.ValueKind is JsonValueKind.String or JsonValueKind.Null or JsonValueKind.Undefined
|
||||
|| !properties.TryGetProperty(name, out var schema)
|
||||
|| !IsStringOnly(schema))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized ??= new Dictionary<string, JsonElement>(arguments);
|
||||
normalized[name] = JsonSerializer.SerializeToElement(value.GetRawText());
|
||||
}
|
||||
|
||||
if (normalized is not null)
|
||||
{
|
||||
parameters.Arguments = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Nullable metin parametresi semada ["string", "null"] olarak gelir; baska tip de kabul eden bir
|
||||
// birlesim tipi donusturulmez, degeri SDK oldugu gibi baglar.
|
||||
private static bool IsStringOnly(JsonElement schema)
|
||||
{
|
||||
if (!schema.TryGetProperty("type", out var type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return type.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => type.GetString() == "string",
|
||||
JsonValueKind.Array => type.EnumerateArray()
|
||||
.Select(item => item.GetString())
|
||||
.Where(item => item != "null")
|
||||
.SequenceEqual(["string"]),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using ModelContextProtocol;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Authorization;
|
||||
|
|
@ -12,12 +13,11 @@ using Volo.Abp.DependencyInjection;
|
|||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Aracin arkasindaki platform servislerinden (wizard, custom component, ListForm verisi) ve sinif
|
||||
/// duzeyindeki <c>[Authorize]</c>'dan gelen istisnalari istemcinin gorebilecegi mesaja cevirir. MCP'nin
|
||||
/// kendi reddi istisna degildir, <see cref="McpProblem"/> olarak doner. SDK cevrilmemis istisnayi
|
||||
/// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde
|
||||
/// eksik yetkiler adiyla, is kurali hatasinda (<see cref="UserFriendlyException"/>) zaten
|
||||
/// yerellestirilmis mesaj iletilir. Diger istisnalar cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
|
||||
/// Arac hatalarini istemcinin gorebilecegi bicime cevirir. SDK yalnizca <see cref="McpException"/>
|
||||
/// mesajini iletir; diger her istisna "An error occurred invoking" olarak gider ve model neden
|
||||
/// reddedildigini bilemez. Yetki reddinde eksik yetkiler adiyla, is kurali hatasinda
|
||||
/// (<see cref="UserFriendlyException"/>) zaten yerellestirilmis mesaj iletilir. Diger istisnalar
|
||||
/// cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
|
||||
/// </summary>
|
||||
public class McpToolErrorTranslator(
|
||||
IPermissionChecker permissionChecker,
|
||||
|
|
@ -26,10 +26,10 @@ public class McpToolErrorTranslator(
|
|||
public static bool CanTranslate(Exception exception)
|
||||
=> exception is AbpAuthorizationException or UserFriendlyException;
|
||||
|
||||
public async Task<string> TranslateAsync(Exception exception, string? toolName)
|
||||
public async Task<McpException> TranslateAsync(Exception exception, string? toolName)
|
||||
=> exception is AbpAuthorizationException authorization
|
||||
? await DescribeMissingPermissionsAsync(authorization, toolName)
|
||||
: exception.Message;
|
||||
? new McpException(await DescribeMissingPermissionsAsync(authorization, toolName), exception)
|
||||
: new McpException(exception.Message, exception);
|
||||
|
||||
/// <summary>
|
||||
/// <c>CheckAsync("...")</c> reddi yetki adini tasir; <c>[Authorize]</c> attribute'u tasimaz. Ikinci
|
||||
|
|
@ -43,11 +43,10 @@ public class McpToolErrorTranslator(
|
|||
candidates.Add(policyName);
|
||||
}
|
||||
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(candidates);
|
||||
var missing = candidates
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(permission => !granted.Contains(permission))
|
||||
.Select(permission => McpPermissionText.Describe(localizer, permission))
|
||||
var grants = await permissionChecker.IsGrantedAsync([.. candidates.Distinct(StringComparer.Ordinal)]);
|
||||
var missing = grants.Result
|
||||
.Where(result => result.Value != PermissionGrantResult.Granted)
|
||||
.Select(result => Describe(result.Key))
|
||||
.ToList();
|
||||
|
||||
var target = toolName ?? McpConsts.EndpointPath;
|
||||
|
|
@ -56,4 +55,14 @@ public class McpToolErrorTranslator(
|
|||
? localizer[McpErrorCodes.PermissionDeniedUnspecified, target]
|
||||
: localizer[McpErrorCodes.PermissionDenied, target, string.Join(", ", missing)];
|
||||
}
|
||||
|
||||
/// <summary>Yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir.</summary>
|
||||
private string Describe(string permissionName)
|
||||
{
|
||||
var displayName = localizer[permissionName];
|
||||
|
||||
return displayName.ResourceNotFound || displayName.Value == permissionName
|
||||
? permissionName
|
||||
: $"{displayName.Value} ({permissionName})";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
namespace Sozsoft.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Arac sonucundaki gorselleri istemcinin boyut sinirina sigdirir. MCP istemcileri tek bir arac
|
||||
/// sonucunu sinirlar (Claude Desktop 1 MB); talep aciklamasina yapistirilan ekran goruntuleri
|
||||
/// tek basina bu siniri asabilir. Gorsel isleme kutuphanesi barindiran uygulamadadir; modul
|
||||
/// grafik paketine baglanmaz.
|
||||
/// </summary>
|
||||
public interface IMcpImageShrinker
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64 uzunlugu <paramref name="maxLength"/>'i asmayan gorsel dondurur: sigiyorsa oldugu gibi,
|
||||
/// sigmiyorsa kucultulmus JPEG. Okunabilir boyutta sigdirilamazsa ya da cozulemezse null.
|
||||
/// </summary>
|
||||
McpShrunkImage? Shrink(string mimeType, string base64, int maxLength);
|
||||
}
|
||||
|
||||
public sealed record McpShrunkImage(string MimeType, string Data);
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -17,43 +16,43 @@ public sealed class McpDataTools(IMcpDataAppService dataAppService)
|
|||
{
|
||||
[McpServerTool(Name = McpToolNames.InsertRow, Destructive = false, Idempotent = false)]
|
||||
[Description("Bir listeye yeni kayit ekler. Alan adlari describe_list cikisindaki field degerleridir.")]
|
||||
public async Task<CallToolResult> InsertRowAsync(
|
||||
public async Task<McpWriteResultDto> InsertRowAsync(
|
||||
[Description("Kaydin eklenecegi listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Alan adi -> deger eslemesi.")]
|
||||
Dictionary<string, object?> values)
|
||||
=> McpToolResult.From(await dataAppService.InsertRowAsync(new McpRowRequestDto
|
||||
=> await dataAppService.InsertRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Values = values ?? []
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.UpdateRow, Destructive = true, Idempotent = true)]
|
||||
[Description("Bir listedeki kaydi anahtar degerine gore gunceller. Yalnizca verilen alanlar yazilir.")]
|
||||
public async Task<CallToolResult> UpdateRowAsync(
|
||||
public async Task<McpWriteResultDto> UpdateRowAsync(
|
||||
[Description("Kaydin bulundugu listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Guncellenecek kaydin anahtar alan degeri; describe_list cikisindaki keyFieldName alanina karsilik gelir.")]
|
||||
List<object?> keys,
|
||||
[Description("Alan adi -> yeni deger eslemesi.")]
|
||||
Dictionary<string, object?> values)
|
||||
=> McpToolResult.From(await dataAppService.UpdateRowAsync(new McpRowRequestDto
|
||||
=> await dataAppService.UpdateRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Keys = keys ?? [],
|
||||
Values = values ?? []
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteRow, Destructive = true, Idempotent = true)]
|
||||
[Description("Bir listedeki kaydi anahtar degerine gore siler. Ekranin kendi silme komutu kullanilir; soft delete tasiyan tablolarda kayit pasife cekilir.")]
|
||||
public async Task<CallToolResult> DeleteRowAsync(
|
||||
public async Task<McpWriteResultDto> DeleteRowAsync(
|
||||
[Description("Kaydin bulundugu listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Silinecek kaydin anahtar alan degeri.")]
|
||||
List<object?> keys)
|
||||
=> McpToolResult.From(await dataAppService.DeleteRowAsync(new McpRowRequestDto
|
||||
=> await dataAppService.DeleteRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Keys = keys ?? []
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,46 +13,46 @@ namespace Sozsoft.Mcp.Tools;
|
|||
public static class McpDevelopPrompts
|
||||
{
|
||||
[McpServerPrompt(Name = McpPromptNames.AnalyzeDevRequest)]
|
||||
[Description("Tek bir gelistirme talebini analiz eder; uretime hazirsa Ready to Start'a, degilse Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")]
|
||||
[Description("Tek bir gelistirme talebini analiz eder ve Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")]
|
||||
public static string AnalyzeDevRequest(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " +
|
||||
=> $"App.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " +
|
||||
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
|
||||
"Talebi ve gorsellerini get_dev_request ile oku; analizi save_dev_request_analysis ile yaz. " +
|
||||
"Hicbir sey uretme (tablo, wizard, bilesen yok) ve Description'a dokunma. " +
|
||||
"Arac reddederse (talep Backlog ya da Analysis degil, bulunamadi ya da birden fazla eslesme var) nedenini bildir ve dur. " +
|
||||
"Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar, Uretime hazir satiri ve talebin yeni durumu (Ready to Start ya da Analysis).";
|
||||
"Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar ve Uretime hazir satiri; talebi Ready to Start'a kullanicinin alacagini belirt.";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)]
|
||||
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder; uretime hazir olanlari Ready to Start'a, digerlerini Analysis durumuna alir.")]
|
||||
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder ve Analysis durumuna alir.")]
|
||||
public static string AnalyzeBacklogRequests()
|
||||
=> "Sas.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " +
|
||||
=> "App.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " +
|
||||
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
|
||||
"Her talebi get_dev_request ile oku, analizi save_dev_request_analysis ile yaz; reddedilen talebi atla ve rapora yaz. " +
|
||||
"Hicbir sey uretme ve Description'a dokunma. " +
|
||||
"Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi, Uretime hazir durumu ve talebin yeni durumu (Ready to Start ya da Analysis).";
|
||||
"Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi ve Uretime hazir durumu; talepleri Ready to Start'a kullanicinin alacagini tek satirda belirt.";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.ProcessDevRequest)]
|
||||
[Description("Tek bir gelistirme talebini isler. Talep Ready to Start durumunda olmalidir.")]
|
||||
public static string ProcessDevRequest(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " +
|
||||
=> $"App.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " +
|
||||
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
|
||||
"Talebi start_dev_request ile ustlen. Arac reddederse (talep Ready to Start degil, bulunamadi ya da " +
|
||||
"birden fazla eslesme var) hicbir sey uretme, baska bir talebe gecme: nedenini bildir ve dur. " +
|
||||
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
|
||||
"Testing'e almadan once uretilen ekranin modul dokumani bolumunu get_module_doc ile ekran koduyla oku, todo alt bolumlerini save_module_doc ile ayni kodla doldur (lowcode 0.13); sonra talebi Testing'e alip bitir. " +
|
||||
"Testing'e almadan once uretilen modulun dokumanini get_module_doc ile oku, todo bolumlerini save_module_doc ile doldur (lowcode 0.13); sonra talebi Testing'e alip bitir. " +
|
||||
"Sonunda rapor ver: uretilen artefaktlar, talebin son durumu ve DB Migrate gerektigi.";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.ProcessReadyRequests)]
|
||||
[Description("Ready to Start durumundaki tum gelistirme taleplerini SortOrder sirasiyla isler.")]
|
||||
public static string ProcessReadyRequests()
|
||||
=> "Sas.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " +
|
||||
=> "App.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " +
|
||||
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
|
||||
"Her talebi start_dev_request ile ustlen; reddedilen talebi atla ve rapora yaz. " +
|
||||
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
|
||||
"Her talepte Testing'e almadan once ekranin modul dokumani bolumunu save_module_doc ile doldur (lowcode 0.13), sonra talebi Testing'e alip digerine gec. " +
|
||||
"Her talepte Testing'e almadan once modul dokumanini save_module_doc ile doldur (lowcode 0.13), sonra talebi Testing'e alip digerine gec. " +
|
||||
"Sonunda parti raporu ver: talep basina uretilen artefaktlar ve son durum; DB Migrate gerektigini tek satirda belirt.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -18,208 +17,155 @@ namespace Sozsoft.Mcp.Tools;
|
|||
/// yolla ayni sekilde olusur.
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMcpImageShrinker imageShrinker)
|
||||
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
|
||||
{
|
||||
private static readonly JsonSerializerOptions TextOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Talep sonucunun (metin + base64 gorseller) ust siniri, karakter. Claude Desktop arac sonucunu
|
||||
/// 1 MB ile sinirlar; JSON-RPC zarfi ve kacislar icin pay birakilir.
|
||||
/// </summary>
|
||||
private const int MaxResultLength = 900_000;
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetDevGuide, ReadOnly = true)]
|
||||
[Description("Ekran ve bilesen uretim sozlesmesini okur: wizard alanlari, enum degerleri, editor/lookup/script sozlesmesi, custom component kurallari. section bos ise baslik listesi doner; once listeyi al, sonra ihtiyac duydugun bolumu numarasiyla iste (ornegin 4.2). Rehberde dosya yazmak anlatilan yerlerde dosya yerine bu sunucunun araclarini kullan.")]
|
||||
public async Task<CallToolResult> GetDevGuideAsync(
|
||||
public async Task<McpDevGuideDto> GetDevGuideAsync(
|
||||
[Description("'lowcode' (uretim protokolu ve artefakt semalari, varsayilan) ya da 'reference' (JSON kolon sozlesmeleri, tasarimci envanteri).")]
|
||||
string? document = null,
|
||||
[Description("Bolum numarasi (4.2), baslik metninin bir parcasi ya da aranacak terim (dxImageUpload). Baslikla eslesmeyen terimde terimin gectigi bolumler listelenir; bos birakilirsa baslik listesi doner.")]
|
||||
string? section = null)
|
||||
=> McpToolResult.From(await developAppService.GetDevGuideAsync(document, section));
|
||||
=> await developAppService.GetDevGuideAsync(document, section);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.CreateTable, Destructive = false, Idempotent = false)]
|
||||
[Description("Ekranin baglanacagi yeni tablonun script'ini SQL Query Manager tablo tasarimcisiyla ayni bicimde uretir ve seed dosyasina (sql/object + postgres/object) yazar; veritabaninda calistirmaz, tabloyu kullanicinin calistirdigi DB Migrate kurar. DB Migrate'i bekleme ve calistirmaya calisma; deploy_wizard kolonlari script'ten okur. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. indexes ile index/unique, foreignKeys ile FK kisitlari ayni scripte yazilir. Yalnizca yeni tablo icindir: tablo ya da script'i varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. View/procedure/function icin save_sql_object kullan. Ardindan deploy_wizard ile ekrani kur.")]
|
||||
public async Task<CallToolResult> CreateTableAsync(
|
||||
[Description("Ekranin baglanacagi yeni tabloyu SQL Query Manager tablo tasarimcisiyla ayni bicimde olusturur ve script'i seed dosyasina (sql/object) yazar. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. Yalnizca yeni tablo olusturur: tablo varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. SQL Server veri kaynaginda calisir. Ardindan deploy_wizard ile ekrani kur.")]
|
||||
public async Task<McpCreateTableResultDto> CreateTableAsync(
|
||||
[Description("Tablo adi; harf/rakam/alt cizgi. Adlandirma icin get_dev_guide'daki tablo onek kuralina uy (ornegin Adm_T_...).")]
|
||||
string tableName,
|
||||
[Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (her zaman 18,4 yazilir; boyut verme), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME(). Sabit defaultValue ekranda yeni kaydin form degeri olur (deploy tablodan okur); bu yuzden IsActive bit isNullable false defaultValue \"1\", olcu birimi (UomId) defaultValue \"'Adet'\", para birimi (CurrencyId) defaultValue \"'TRY'\" ve talepteki her sabit varsayilan (Sure = 60 → \"60\") kolona yazilir (get_dev_guide 4.3.5.1).")]
|
||||
[Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (her zaman 18,4 yazilir; boyut verme), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME().")]
|
||||
List<McpTableColumnDto> columns,
|
||||
[Description("Tam audit kolonlari eklensin mi. Kullanici acikca istemedikce true birak.")]
|
||||
bool fullAudited = true,
|
||||
[Description("TenantId kolonu eklensin mi. Kullanici acikca istemedikce true birak.")]
|
||||
bool multiTenant = true,
|
||||
[Description("Index ve unique kisitlari: [{\"columns\": [\"Code\"], \"unique\": true}]. Ad sunucuda IX_/UQ_{Tablo}_{Kolonlar} olarak uretilir. Kiraci tablosunda unique kisita TenantId'yi de ekle.")]
|
||||
List<McpTableIndexDto>? indexes = null,
|
||||
[Description("FK'ler: [{\"column\": \"UomId\", \"referencedTable\": \"Adm_T_Uom\", \"referencedColumn\": \"Id\", \"onDelete\": \"NoAction\"}]. onDelete: NoAction (varsayilan), Cascade, SetNull. Migrate scriptleri dosya adi sirasiyla calistirdigi icin hedef tablo ya scripti bu tablonunkinden once siralanan bir seed tablosu ya da veritabaninda kurulu bir platform tablosu olmalidir; degilse reddedilir.")]
|
||||
List<McpTableForeignKeyDto>? foreignKeys = null)
|
||||
=> McpToolResult.From(await developAppService.CreateTableAsync(new McpCreateTableRequestDto
|
||||
bool multiTenant = true)
|
||||
=> await developAppService.CreateTableAsync(new McpCreateTableRequestDto
|
||||
{
|
||||
TableName = tableName,
|
||||
Columns = columns,
|
||||
FullAudited = fullAudited,
|
||||
MultiTenant = multiTenant,
|
||||
Indexes = indexes ?? [],
|
||||
ForeignKeys = foreignKeys ?? []
|
||||
}));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveSqlObject, Destructive = true, Idempotent = true)]
|
||||
[Description("Ekranin veri kaynagi ya da lookup'i olacak view, stored procedure ya da function scriptini SQL Query Manager'in yazdigi yere (sql/object/{Ad}.sql) ve PostgreSQL esine (postgres/object/{Ad}.sql) yazar; ad scriptteki nesne adidir; veritabaninda calistirmaz, nesneyi kullanicinin calistirdigi DB Migrate kurar. Uygulama iki saglayicida calistigi icin iki script de zorunludur ve ayni nesneyi ayni parametre sirasiyla tanimlar (SQL Server: [dbo], PostgreSQL: public, ceviri kurallari get_dev_guide lowcode 8.3.1). Baslik tekrar calismaya dayanikli bicime cevrilir (CREATE OR ALTER / CREATE OR REPLACE); ayni adla tekrar cagrilirsa script guncellenir. Migrate object scriptlerinde once tablolari, sonra fonksiyonlari, en son view/yordamlari kurar (tur icinde dosya adi sirasi): view'in okudugu baska bir view'in adi ondan once siralanmalidir. Birden fazla tablodan beslenen ekranda Query (SelectCommandType 4) yerine bu aracla view yazip ekrani view'e (SelectCommandType 2) bagla. Tablo icin create_table kullan.")]
|
||||
public async Task<CallToolResult> SaveSqlObjectAsync(
|
||||
[Description("SQL Server scripti: tek bir CREATE [OR ALTER] VIEW/PROCEDURE/FUNCTION [dbo].[Ad] tanimi; GO ile biten batch'ler kullanilabilir. Dosya adi nesnenin adidir.")]
|
||||
string sqlServerScript,
|
||||
[Description("PostgreSQL scripti: ayni nesnenin CREATE OR REPLACE VIEW/FUNCTION/PROCEDURE public.\"Ad\" tanimi.")]
|
||||
string postgreSqlScript)
|
||||
=> McpToolResult.From(await developAppService.SaveSqlObjectAsync(sqlServerScript, postgreSqlScript));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GenerateCrudEndpoints, Destructive = true, Idempotent = true)]
|
||||
[Description("Bir tablo ya da view icin SQL Query Manager'daki CRUD endpoint diyaloguyla ayni REST endpoint'lerini (/api/app/crudendpoint/{entity}: GetList, GetById, Create, Update, Delete) yalnizca crud/{entity}.json seed dosyasina yazar; kayitlari DB Migrate ekler (migrate'i bekleme). Ayni entity icin tekrar cagrilirsa dosya yeniden yazilir. Custom component'in veri okuyup yazacagi tablo icin kullanilir; ListForm ekranlari bu endpoint'lere ihtiyac duymaz. View/procedure/function icin yalnizca GetList ve GetById uretilir. Sas.SqlQueryManager.CrudEndpoints yetkisi ister.")]
|
||||
public async Task<CallToolResult> GenerateCrudEndpointsAsync(
|
||||
[Description("Tablo ya da view adi, ornegin 'Stk_T_Product' (sema oneki atilir).")]
|
||||
string entityName,
|
||||
[Description("Aktif baslayacak operasyonlar, virgulle: GetList,GetById,Create,Update,Delete. Bos ise hepsi aktif; digerleri pasif kaydedilir.")]
|
||||
string? operationTypes = null,
|
||||
[Description("Nesne turu: table (varsayilan), view, procedure, function.")]
|
||||
string? objectType = null)
|
||||
=> McpToolResult.From(await developAppService.GenerateCrudEndpointsAsync(entityName, operationTypes, objectType));
|
||||
MultiTenant = multiTenant
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.ListWizards, ReadOnly = true)]
|
||||
[Description("Wizard File Manager'daki wizard dosyalarini listeler: dosya adi, ekran kodu, yol (List/Custom), menu yolu ve bagimli wizard'lar.")]
|
||||
public async Task<CallToolResult> ListWizardsAsync(
|
||||
public async Task<List<WizardFileInfoDto>> ListWizardsAsync(
|
||||
[Description("Dosya adi, wizard adi, ekran kodu ya da bilesen adi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> McpToolResult.From(await developAppService.GetWizardsAsync(search));
|
||||
=> await developAppService.GetWizardsAsync(search);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)]
|
||||
[Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur: tek Wizard bolumu; Menu/Language/Data/Details/Layout basliklari, ekranin her sekmesi bir blok (Editing, Insert/Update/Delete, FormDefaults, FilterRow, HeaderFilter, Selection, ColumnOption, Pager, StateStoring, ChartOptions …) ve Groups altinda alanlarin butun detay sekmeleri (Filter, Header, Grouping, Summary, Join, ValidationRules, Styling, Permission, PivotSettings). Var olan bir ekrani degistirmeden once oku.")]
|
||||
public async Task<CallToolResult> GetWizardAsync(
|
||||
[Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur (Wizard / ListForm / Fields bolumleri). Var olan bir ekrani degistirmeden once oku.")]
|
||||
public async Task<McpWizardFileDto> GetWizardAsync(
|
||||
[Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")]
|
||||
string fileName)
|
||||
=> McpToolResult.From(await developAppService.GetWizardAsync(fileName));
|
||||
=> await developAppService.GetWizardAsync(fileName);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeployWizard, Destructive = true, Idempotent = false)]
|
||||
[Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Ust menu zinciri istenen derinlikte kurulur: MenuParentCode ekranin dogrudan ust menusu, Menu.MenuParentPath onun ustundeki seviyeler (kokten asagi, her biri {Code, Icon, En, Tr}); eksik her seviye bir ustunun altina kurulur, var olana baglanilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. SeedData ekrandan girilen kayitlari dosyaya yansitmaz (SeedSyncInsert/Update/Delete kapali); bunu yalnizca istek acikca isterse SeedSync true yaz. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir. Ekranin her ayari Wizard bolumundedir ve adlari get_wizard ciktisindaki gibidir: liste ayarlari bloklarda (Details.PageSize, Editing, Insert/Update/Delete.FieldsDefaultValue, FormDefaults, ExtraFilters, CommandColumns, FilterRow … ChartOptions), alan ayarlari Groups[].Items[] ogesinde (Format, Alignment, Width, Visible, BandName, ListOrderNo, ValidationRules, TotalSummary/GroupSummary, Grouping, Join, Styling, PivotSettings …). Yazmadigin blok deploy'da varsayilaniyla dolar; ListForm/Fields bolumleri kabul edilmez. Yeni kaydin form degeri (Aktif = true, Birim = Adet, Kur = TRY, Sure = 60) ayri yazilmaz: deploy kolonun tablodaki sabit DEFAULT degerini okur; tablodan farkli deger gerekiyorsa alan ogesinde DefaultValue yaz (Groups[].Items[].DefaultValue). StaticData lookup seceneklerinde (RadioGroup, SelectBox, TagBox) name dil anahtaridir (Sas.StaticLookup.{Key}); EN + TR metni bilinen secenegi Language.Texts'te ver, duz metin yazilirsa deploy onu anahtara cevirip TR'yi metinden yazar (get_dev_guide 4.3.4.1). EditorScript'i elle yazma: Script Builder kurallarini ({v:1, rules:[...]}, get_dev_guide 5.3) ver, deploy kodu Script Builder dialogu ile birebir ayni uretir; govde yazsan da atilir. Onay kutusu alanina setValue text 'true'/'false' (0/1 cevrilir). set ile yazilan alanin kurallari da sirayla calisir (derinlik siniri yok); A->B->A dongusu kuran kural yazma, calisma aninda hata olarak gosterilir. Wizard girdisinde taninmayan anahtar adiyla reddedilir, baska adla tekrar deneme. Guncellemede get_wizard ciktisini butunuyle alip degistirerek gonder: dosyadaki her blok cevaptir, yazilmayan blok varsayilana doner. Kimlik ve yetki alanlari (WizardCode, Title/Desc anahtarlari, Permission adlari, popup basligi) ekran kodundan yeniden turetilir. Liste ekraninda DescTr ve DescEn ayrintili olmali (en az 3 satir, 150 karakter): ekran basligindaki bilgi simgesinde gosterilir; ne ise yaradigi, menude nerede ya da hangi ekranin sekmesi oldugu, ana alanlar, otomatik hesaplar ve iliskili ekranlar yazilir (get_dev_guide 4.4.2); kisa aciklama reddedilir. Deploy modulun dokumanini (docs/modules) acar ya da envanterini yeniler; elle yazilan bolumleri save_module_doc ile doldur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")]
|
||||
public async Task<CallToolResult> DeployWizardAsync(
|
||||
[Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi; Menu/Language/Data/Details/Layout basliklari toplanmis) ya da duz wizard cevaplari. Bloklar istege baglidir; yazilmayan blok varsayilaniyla dolar. Enum alanlari sayi olarak yazilir.")]
|
||||
[Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Ust menu zinciri istenen derinlikte kurulur: MenuParentCode ekranin dogrudan ust menusu, Menu.MenuParentPath onun ustundeki seviyeler (kokten asagi, her biri {Code, Icon, En, Tr}); eksik her seviye bir ustunun altina kurulur, var olana baglanilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir; ekran wizard cevaplarindan yeniden turetildigi icin tasarimcida yapilmis ve wizard'da karsiligi olmayan ayarlar kaybolur. Liste ekraninda DescTr ve DescEn ayrintili olmali (en az 3 satir, 150 karakter): ekran basligindaki bilgi simgesinde gosterilir; ne ise yaradigi, menude nerede ya da hangi ekranin sekmesi oldugu, ana alanlar, otomatik hesaplar ve iliskili ekranlar yazilir (get_dev_guide 4.4.2); kisa aciklama reddedilir. Deploy modulun dokumanini (docs/modules) acar ya da envanterini yeniler; elle yazilan bolumleri save_module_doc ile doldur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")]
|
||||
public async Task<WizardFileInfoDto?> DeployWizardAsync(
|
||||
[Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi) ya da duz wizard cevaplari. Enum alanlari sayi olarak yazilir.")]
|
||||
string wizard,
|
||||
[Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak. Guncelleme dosyayi tamamen degistirir: wizard girdisi get_wizard ile okunan dosyanin degistirilmis tam halidir; Wizard bolumu (WizardCode, Menu, Language, Groups) eksiksiz gonderilir.")]
|
||||
[Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak.")]
|
||||
string? replaceFileName = null,
|
||||
[Description("Demo satirlar: JSON nesne dizisi, anahtarlar ekranin alan adlari ([{\"Name\": \"...\"}]). Guid anahtar, TenantId ve audit alanlarini yazma; sunucu ve migrate doldurur. Yalnizca SeedData true olan liste ekraninda.")]
|
||||
string? seedRows = null)
|
||||
=> McpToolResult.From(await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows }));
|
||||
=> await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows });
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteWizard, Destructive = true, Idempotent = true)]
|
||||
[Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve Sas.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")]
|
||||
public async Task<CallToolResult> DeleteWizardAsync(
|
||||
[Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve App.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")]
|
||||
public async Task<WizardDeleteResultDto> DeleteWizardAsync(
|
||||
[Description("Silinecek wizard dosyasinin adi (list_wizards cikisindaki fileName).")]
|
||||
string fileName,
|
||||
[Description("Ekranin tablosu da dusurulsun mu. Yalnizca kullanici acikca onayladiysa true.")]
|
||||
bool dropTables = false)
|
||||
=> McpToolResult.From(await developAppService.DeleteWizardAsync(fileName, dropTables));
|
||||
=> await developAppService.DeleteWizardAsync(fileName, dropTables);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.VerifyWizard, ReadOnly = true)]
|
||||
[Description("Deploy edilip DB Migrate ile kurulmus bir wizard'i veri yazmadan dogrular (migrate bekleyen wizard'da install kontrolu basarisiz doner ve diger kontroller yapilmaz): menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. Istege baglidir: kullanici migrate sonrasi dogrulama isterse cagir; gelistirme talebi dongusunun adimi degildir.")]
|
||||
public async Task<CallToolResult> VerifyWizardAsync(
|
||||
public async Task<McpVerifyResultDto> VerifyWizardAsync(
|
||||
[Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")]
|
||||
string fileName)
|
||||
=> McpToolResult.From(await developAppService.VerifyWizardAsync(fileName));
|
||||
=> await developAppService.VerifyWizardAsync(fileName);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)]
|
||||
[Description("Sas.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")]
|
||||
public async Task<CallToolResult> StartDevRequestAsync(
|
||||
[Description("App.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")]
|
||||
public async Task<IList<ContentBlock>> StartDevRequestAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi. Ayni basligi tasiyan birden fazla talep varsa Id ver.")]
|
||||
string request)
|
||||
=> McpToolResult.From(await developAppService.StartDevRequestAsync(request), ToContent);
|
||||
=> ToContent(await developAppService.StartDevRequestAsync(request));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetDevRequest, ReadOnly = true)]
|
||||
[Description("Bir gelistirme talebini durumuna dokunmadan okur; start_dev_request ile ayni cevabi (aciklama + ayri resim icerikleri) dondurur. Analiz oncesi talebi okumak, yarim kalmis (In Progress/Testing) bir talebe donmek ya da gorselleri yeniden gormek icin kullan; talebi ustlenmek icin degil.")]
|
||||
public async Task<CallToolResult> GetDevRequestAsync(
|
||||
public async Task<IList<ContentBlock>> GetDevRequestAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> McpToolResult.From(await developAppService.GetDevRequestAsync(request), ToContent);
|
||||
=> ToContent(await developAppService.GetDevRequestAsync(request));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)]
|
||||
[Description("Backlog, Analysis ya da Ready to Start durumundaki bir talebin analizini (HTML) Analysis alanina yazar; var olan analiz degistirilir, Description'a dokunulmaz. productionReady true ise talep Ready to Start'a, false ise Analysis durumuna alinir. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir.")]
|
||||
public async Task<CallToolResult> SaveDevRequestAnalysisAsync(
|
||||
[Description("Backlog ya da Analysis durumundaki bir talebin analizini (HTML) Analysis alanina yazar ve talebi Analysis durumuna alir; var olan analiz degistirilir, Description'a dokunulmaz. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir. Analizi begenen kullanici talebi Ready to Start'a kendisi alir; MCP almaz.")]
|
||||
public async Task<IList<ContentBlock>> SaveDevRequestAnalysisAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Analizin tamami, HTML.")]
|
||||
string html,
|
||||
[Description("Analizin 'Uretime hazir' satiri kosulsuz Evet ise true; sartli (onay bekleyen, 'Evet, ancak …') ya da Hayir ise false. HTML'deki satirla ayni olmak zorundadir.")]
|
||||
bool productionReady)
|
||||
=> McpToolResult.From(await developAppService.SaveDevRequestAnalysisAsync(request, html, productionReady), ToContent);
|
||||
string html)
|
||||
=> ToContent(await developAppService.SaveDevRequestAnalysisAsync(request, html));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.AppendDevRequestNote, Destructive = false, Idempotent = false)]
|
||||
[Description("Talebin Analysis alaninin sonuna HTML ekler; var olan analize ve Description'a dokunmaz. Description talebin yazildigi haliyle kalir; uretim sonuc notunu (<h4>MCP</h4> + uretilenler, varsayimlar ve DB Migrate bekleyen wizard'lar) bu aracla ekle. Description'i ya da Analysis'i update_row ile yazma.")]
|
||||
public async Task<CallToolResult> AppendDevRequestNoteAsync(
|
||||
public async Task<string> AppendDevRequestNoteAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Eklenecek HTML parcasi.")]
|
||||
string html)
|
||||
=> McpToolResult.From(await developAppService.AppendDevRequestNoteAsync(request, html));
|
||||
{
|
||||
await developAppService.AppendDevRequestNoteAsync(request, html);
|
||||
return "ok";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = McpToolNames.AddDevRequestSubTasks, Destructive = false, Idempotent = false)]
|
||||
[Description("Talebin Sub Tasks listesine uretilen her artefakt icin tamamlanmis bir satir ekler; atanan kisi baglanan kullanici, bitis tarihi bugun olarak sunucuda yazilir, var olan satirlar korunur. SubTasks alanini update_row ile yazma.")]
|
||||
public async Task<CallToolResult> AddDevRequestSubTasksAsync(
|
||||
public async Task<string> AddDevRequestSubTasksAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Eklenecek satir basliklari, ornegin [\"create_table: Hr_T_Certificate\", \"deploy_wizard: Certificates.json\"].")]
|
||||
List<string> titles)
|
||||
=> McpToolResult.From(await developAppService.AddDevRequestSubTasksAsync(request, titles));
|
||||
{
|
||||
await developAppService.AddDevRequestSubTasksAsync(request, titles);
|
||||
return "ok";
|
||||
}
|
||||
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetModuleDoc, ReadOnly = true)]
|
||||
[Description("Modul dokumanini (docs/modules/{Menu}.md, seed klasoru; kok menu basina tek dosya, adi kok menu kodu: Hr, Prc, Adm) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. Menunun altindaki her dal dosyada bir bolumdur; ekranin ya da dalin kodu (Hr.PerformanceReview, Hr.Performance) ya da wizard dosya adi verilirse yalnizca o bolum ve envanteri doner, dosya adi verilirse dosyanin tamami. isComplete false ise missingSections doldurulmamis alt bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu ekran koduyla oku; deploy_wizard dosyayi ve dalin bolumunu yoksa kendisi acar. Menude henuz olmayan yeni modulun kodu (Stk, Stk.Stock) hata vermez: exists false ve doldurulacak iskelet doner; save_module_doc ayni kodla yazinca dosya acilir.")]
|
||||
public async Task<CallToolResult> GetModuleDocAsync(
|
||||
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
|
||||
[Description("Modul dokumanini (docs/modules/{Modul}.md, seed klasoru) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. isComplete false ise missingSections doldurulmamis bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu oku. Ad yerine ekranin kodu (App.Hr.PerformanceReview) ya da wizard dosya adi da verilebilir; deploy_wizard dokumani yoksa iskeletini kendisi acar.")]
|
||||
public async Task<ModuleDocDto> GetModuleDocAsync(
|
||||
[Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
|
||||
string name)
|
||||
=> McpToolResult.From(await developAppService.GetModuleDocAsync(name));
|
||||
=> await developAppService.GetModuleDocAsync(name);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveModuleDoc, Destructive = true, Idempotent = true)]
|
||||
[Description("Modul dokumaninin elle yazilan bolumunu kaydeder (Markdown). Uretimin parcasidir: deploy_wizard'dan sonra get_module_doc ile ekranin bolumunu oku, module-doc:todo isaretli her alt bolumu (Amac ve kapsam, Analiz ve kararlar — onaylanmis analiz ve kararlar, Menu ve ekranlar, Veri modeli, Is kurallari ve hesaplar, Yetki ve gorunurluk, Entegrasyonlar, Akis ornegi, Sik sorulanlar, Dosyalar ve geri alma) doldurup isaretleri kaldirarak ayni ekran ya da dal koduyla gonder: yalnizca o bolum yazilir, diger dallara dokunulmaz. Bos kalacak alt bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Bolumun '<!-- module-doc:section ... claims=... -->' beyan satirini gondermezsen mevcut beyan korunur. Kok menu koduyla (Hr) cagrilirsa dosyanin tamami yazilir. Talep, bolumu tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")]
|
||||
public async Task<CallToolResult> SaveModuleDocAsync(
|
||||
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
|
||||
[Description("Modul dokumaninin elle yazilan bolumlerini kaydeder (Markdown, dosyanin tamami). Uretimin parcasidir: deploy_wizard'dan sonra once get_module_doc ile iskeleti oku, module-doc:todo isaretli her bolumu (Amac ve kapsam, Analiz ve kararlar — onaylanmis analiz ve kararlar, Menu ve ekranlar, Veri modeli, Is kurallari ve hesaplar, Yetki ve gorunurluk, Entegrasyonlar, Akis ornegi, Sik sorulanlar, Dosyalar ve geri alma) doldurup isaretleri kaldirarak gonder; bos kalacak bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Ilk satirdaki '<!-- module-doc: claims=... -->' beyanini koru. Talep, dokumanlari tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")]
|
||||
public async Task<ModuleDocDto> SaveModuleDocAsync(
|
||||
[Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
|
||||
string name,
|
||||
[Description("Bolumun (ya da dosya adi verildiyse dosyanin) tamami, Markdown.")]
|
||||
[Description("Dokumanin tamami, Markdown.")]
|
||||
string content)
|
||||
=> McpToolResult.From(await developAppService.SaveModuleDocAsync(name, content));
|
||||
=> await developAppService.SaveModuleDocAsync(name, content);
|
||||
|
||||
/// <summary>
|
||||
/// Talep metni JSON olarak, gorseller ayri resim icerigi olarak doner; model resmi ancak bu
|
||||
/// bicimde gorur, base64 metni olarak goremez. Sonucun tamami istemcinin sinirina sigar: kalan
|
||||
/// pay gorsellere esit bolunur, payini asan gorsel kucultulur, kucultulse de sigmayan gonderilmez
|
||||
/// ve hangisinin eksik oldugu metinde bildirilir.
|
||||
/// bicimde gorur, base64 metni olarak goremez.
|
||||
/// </summary>
|
||||
private List<ContentBlock> ToContent(McpDevRequestDto devRequest)
|
||||
private static List<ContentBlock> ToContent(McpDevRequestDto devRequest)
|
||||
{
|
||||
var text = JsonSerializer.Serialize(devRequest, TextOptions);
|
||||
List<ContentBlock> content = [new TextContentBlock { Text = text }];
|
||||
List<ContentBlock> content = [new TextContentBlock { Text = JsonSerializer.Serialize(devRequest, TextOptions) }];
|
||||
|
||||
var remaining = MaxResultLength - text.Length;
|
||||
var omitted = new List<string>();
|
||||
|
||||
for (var i = 0; i < devRequest.Images.Count; i++)
|
||||
foreach (var image in devRequest.Images)
|
||||
{
|
||||
var image = devRequest.Images[i];
|
||||
var share = Math.Max(0, remaining) / (devRequest.Images.Count - i);
|
||||
var shrunk = imageShrinker.Shrink(image.MimeType, image.Data, share);
|
||||
if (shrunk is null)
|
||||
{
|
||||
omitted.Add(image.Placeholder);
|
||||
continue;
|
||||
}
|
||||
|
||||
remaining -= shrunk.Data.Length;
|
||||
|
||||
// Veri zaten base64; cozup yeniden kodlamak buyuk gorsellerde bosuna bellek harcar.
|
||||
content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(shrunk.Data), MimeType = shrunk.MimeType });
|
||||
}
|
||||
|
||||
if (omitted.Count > 0)
|
||||
{
|
||||
content.Add(new TextContentBlock
|
||||
{
|
||||
Text = $"Boyut siniri nedeniyle gonderilmeyen gorseller: {string.Join(", ", omitted)}. Gerekirse kullanicidan gorselin ne gosterdigini sor."
|
||||
});
|
||||
content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(image.Data), MimeType = image.MimeType });
|
||||
}
|
||||
|
||||
return content;
|
||||
|
|
@ -227,21 +173,21 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
|
|||
|
||||
[McpServerTool(Name = McpToolNames.ListComponents, ReadOnly = true)]
|
||||
[Description("Custom component'leri ad, rota ve aciklamasiyla listeler; kod dondurmez. isVisualDesigner true olanlarin kodu tasarimci belgesinden uretilir ve MCP ile degistirilemez.")]
|
||||
public async Task<CallToolResult> ListComponentsAsync(
|
||||
public async Task<List<McpComponentSummaryDto>> ListComponentsAsync(
|
||||
[Description("Ad, rota ya da aciklama icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> McpToolResult.From(await developAppService.GetComponentsAsync(search));
|
||||
=> await developAppService.GetComponentsAsync(search);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetComponent, ReadOnly = true)]
|
||||
[Description("Bir custom component'in kodunu, props'unu ve bagimliliklarini dondurur. Degistirmeden once oku.")]
|
||||
public async Task<CallToolResult> GetComponentAsync(
|
||||
public async Task<CustomComponentDto?> GetComponentAsync(
|
||||
[Description("Bilesenin adi.")]
|
||||
string name)
|
||||
=> McpToolResult.From(await developAppService.GetComponentAsync(name));
|
||||
=> await developAppService.GetComponentAsync(name);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveComponent, Destructive = true, Idempotent = true)]
|
||||
[Description("Custom component'i ada gore kaydeder: yoksa ekler, varsa gunceller. Guncellemede verilmeyen alanlar korunur. Kod kurallari ve kullanilabilir kutuphaneler icin get_dev_guide 7. bolumu. Bilesen menude gorunecekse ardindan ComponentKind 1 ile deploy_wizard cagir.")]
|
||||
public async Task<CallToolResult> SaveComponentAsync(
|
||||
public async Task<CustomComponentDto> SaveComponentAsync(
|
||||
[Description("Bilesenin adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Bilesenin React kodu. Yeni bilesende zorunlu, guncellemede bos birakilirsa mevcut kod korunur.")]
|
||||
|
|
@ -256,7 +202,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
|
|||
string? dependencies = null,
|
||||
[Description("Bilesen etkin mi. Yeni bilesende varsayilan true.")]
|
||||
bool? isActive = null)
|
||||
=> McpToolResult.From(await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
|
||||
=> await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Code = code,
|
||||
|
|
@ -265,12 +211,12 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
|
|||
Props = props,
|
||||
Dependencies = dependencies,
|
||||
IsActive = isActive
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteComponent, Destructive = true, Idempotent = false)]
|
||||
[Description("Custom component'i siler. Bilesene bagli bir Custom wizard varsa once delete_wizard ile o silinir.")]
|
||||
public async Task<CallToolResult> DeleteComponentAsync(
|
||||
public async Task DeleteComponentAsync(
|
||||
[Description("Bilesenin adi.")]
|
||||
string name)
|
||||
=> McpToolResult.From(await developAppService.DeleteComponentAsync(name));
|
||||
=> await developAppService.DeleteComponentAsync(name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -13,7 +12,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
{
|
||||
[McpServerTool(Name = McpToolNames.QueryList, ReadOnly = true)]
|
||||
[Description("Bir listenin verisini uygulamadaki ekranla ayni sorguyla okur; kullanicinin liste ve alan yetkileri, kiraci ve sube filtreleri gecerlidir. Veri okumak icin once bunu kullan. Alan adlari describe_list cikisindaki field degerleridir. Filtre/siralama/gruplama DevExtreme bicimindedir.")]
|
||||
public async Task<CallToolResult> QueryListAsync(
|
||||
public async Task<McpListQueryResultDto> QueryListAsync(
|
||||
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
|
||||
string listFormCode,
|
||||
[Description("Filtre, JSON metni. Ornek: [\"Status\",\"=\",\"Open\"] ya da [[\"Amount\",\">\",100],\"and\",[\"Name\",\"contains\",\"abc\"]]. Operatorler: =, <>, >, >=, <, <=, contains, notcontains, startswith, endswith.")]
|
||||
|
|
@ -30,7 +29,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
int skip = 0,
|
||||
[Description("Istenen kayit sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int take = 0)
|
||||
=> McpToolResult.From(await queryAppService.QueryListAsync(new McpListQueryRequestDto
|
||||
=> await queryAppService.QueryListAsync(new McpListQueryRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Filter = filter,
|
||||
|
|
@ -40,34 +39,34 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
TotalSummary = totalSummary,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.RunSelect, ReadOnly = true)]
|
||||
[Description("Yalnizca uygulamada SQL Query Manager yetkisi olan kullanicilar icin: tek bir parametreli SELECT ifadesi kosturur. Liste verisi icin query_list tercih edilir. Coklu ifade, DDL ve DML reddedilir; satir sayisi sunucu kaydindaki tavana kirpilir. Sorguya deger gomme, @ad parametresi kullan. Kolonun var oldugunu varsayma: IsDeleted, TenantId, IsActive her tabloda yoktur; emin degilsen once INFORMATION_SCHEMA.COLUMNS (PostgreSQL: information_schema.columns) uzerinden tablonun kolonlarini oku, filtreyi yalnizca var olan kolona yaz.")]
|
||||
public async Task<CallToolResult> RunSelectAsync(
|
||||
public async Task<McpQueryResultDto> RunSelectAsync(
|
||||
[Description("Tek bir SELECT (ya da WITH ... SELECT) ifadesi. Degerler @ad bicimindeki parametrelerle verilir.")]
|
||||
string sql,
|
||||
[Description("Sorgudaki adli parametreler: anahtar parametre adi, deger ise ham JSON degeri.")]
|
||||
Dictionary<string, object?>? parameters = null,
|
||||
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int maxRows = 0)
|
||||
=> McpToolResult.From(await queryAppService.RunSelectAsync(new McpSelectRequestDto
|
||||
=> await queryAppService.RunSelectAsync(new McpSelectRequestDto
|
||||
{
|
||||
Sql = sql,
|
||||
Parameters = parameters ?? [],
|
||||
MaxRows = maxRows
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.ListQueries, ReadOnly = true)]
|
||||
[Description("Veri kaynagindaki kayitli nesneleri (view, stored procedure, function) ve cagrilabilir Custom Endpoint'leri dondurur. run_query bunlardan birini cagirir.")]
|
||||
public async Task<CallToolResult> ListQueriesAsync(
|
||||
public async Task<List<McpSavedQueryDto>> ListQueriesAsync(
|
||||
[Description("Nesne adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> McpToolResult.From(await queryAppService.GetSavedQueriesAsync(search));
|
||||
=> await queryAppService.GetSavedQueriesAsync(search);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.RunQuery, ReadOnly = true)]
|
||||
[Description("Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir. Nesne turu ve adi list_queries cikisindan alinir.")]
|
||||
public async Task<CallToolResult> RunQueryAsync(
|
||||
public async Task<McpQueryResultDto> RunQueryAsync(
|
||||
[Description("Nesne turu: view, procedure, tableFunction, scalarFunction ya da customEndpoint.")]
|
||||
string kind,
|
||||
[Description("Nesnenin ya da Custom Endpoint'in adi.")]
|
||||
|
|
@ -78,12 +77,12 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
Dictionary<string, object?>? parameters = null,
|
||||
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int maxRows = 0)
|
||||
=> McpToolResult.From(await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto
|
||||
=> await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto
|
||||
{
|
||||
Kind = kind,
|
||||
Name = name,
|
||||
SchemaName = schemaName,
|
||||
Parameters = parameters ?? [],
|
||||
MaxRows = maxRows
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -16,37 +15,37 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
{
|
||||
[McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)]
|
||||
[Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")]
|
||||
public async Task<CallToolResult> ListReportTemplatesAsync(
|
||||
public async Task<McpReportCatalogDto> ListReportTemplatesAsync(
|
||||
[Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> McpToolResult.From(await reportAppService.GetReportTemplatesAsync(search));
|
||||
=> await reportAppService.GetReportTemplatesAsync(search);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)]
|
||||
[Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")]
|
||||
public async Task<CallToolResult> GetReportTemplateAsync(
|
||||
public async Task<McpReportTemplateDto> GetReportTemplateAsync(
|
||||
[Description("Sablonun Id'si ya da adi.")]
|
||||
string template)
|
||||
=> McpToolResult.From(await reportAppService.GetReportTemplateAsync(template));
|
||||
=> await reportAppService.GetReportTemplateAsync(template);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveReportCategory, Destructive = true, Idempotent = true)]
|
||||
[Description("Rapor kategorisini ada gore kaydeder: yoksa ekler, varsa gunceller. Kayit bir ekran kaydindan basilan HTML sablonu ise var olan 'Raporlar' kategorisini kullan; yeni kategori yalnizca istenince acilir.")]
|
||||
public async Task<CallToolResult> SaveReportCategoryAsync(
|
||||
public async Task<McpReportCategoryDto> SaveReportCategoryAsync(
|
||||
[Description("Kategorinin adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Aciklama.")]
|
||||
string? description = null,
|
||||
[Description("Ikon adi.")]
|
||||
string? icon = null)
|
||||
=> McpToolResult.From(await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
|
||||
=> await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Icon = icon
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveReportTemplate, Destructive = true, Idempotent = true)]
|
||||
[Description("DynamicReport sablonunu ada gore kaydeder: yoksa ekler, varsa gunceller; guncellemede verilmeyen alanlar korunur. Sablon A4'e basilan HTML'dir (Standard Taahhutname gibi). listFormCode verilirse HTML icindeki {{AlanAdi}} yer tutuculari o ekranin alanlarina gore dogrulanir ve cevapta rowButton doner: bu butonu ekranin CommandColumnJson listesine ekle (deploy_wizard), VisibleExpression ile kosullandir. Sozlesme icin get_dev_guide 9.12.")]
|
||||
public async Task<CallToolResult> SaveReportTemplateAsync(
|
||||
public async Task<McpReportTemplateDto> SaveReportTemplateAsync(
|
||||
[Description("Sablonun adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")]
|
||||
|
|
@ -59,9 +58,9 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
string? status = null,
|
||||
[Description("Sablonun basilacagi ekranin kodu (ornegin katilimci listesi). Verilirse yer tutucular dogrulanir ve satir butonu doner.")]
|
||||
string? listFormCode = null,
|
||||
[Description("Satir butonunun dil anahtari. Verilmezse Sas.Platform.View.")]
|
||||
[Description("Satir butonunun dil anahtari. Verilmezse App.Platform.View.")]
|
||||
string? buttonText = null)
|
||||
=> McpToolResult.From(await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
|
||||
=> await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Category = category,
|
||||
|
|
@ -70,12 +69,12 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
Status = status,
|
||||
ListFormCode = listFormCode,
|
||||
ButtonText = buttonText
|
||||
}));
|
||||
});
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)]
|
||||
[Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")]
|
||||
public async Task<CallToolResult> DeleteReportTemplateAsync(
|
||||
public async Task DeleteReportTemplateAsync(
|
||||
[Description("Sablonun Id'si ya da adi.")]
|
||||
string template)
|
||||
=> McpToolResult.From(await reportAppService.DeleteReportTemplateAsync(template));
|
||||
=> await reportAppService.DeleteReportTemplateAsync(template);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
|
|
@ -32,29 +31,29 @@ public sealed class McpSchemaTools(IMcpSchemaAppService schemaAppService, ICurre
|
|||
}
|
||||
|
||||
[McpServerTool(Name = McpToolNames.ListLists, ReadOnly = true)]
|
||||
[Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner. Gelistiriciye (host, Wizard yetkisi) DB Migrate bekleyen wizard ekranlari (sourceKind pending) ve yalnizca seed scripti olan tablolar (pendingTable) da doner; lookup, widget, view ve Query SQL'ini yazarken kolon adlarini describe_list ile buradan al, tahmin etme.")]
|
||||
public async Task<CallToolResult> ListListsAsync(
|
||||
[Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner.")]
|
||||
public async Task<List<McpListSummaryDto>> ListListsAsync(
|
||||
[Description("Liste adi, basligi, aciklamasi ya da tablo adi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null,
|
||||
[Description("Donecek en fazla liste sayisi. Varsayilan 200.")]
|
||||
int maxResults = 200)
|
||||
=> McpToolResult.From(await schemaAppService.GetListsAsync(search, maxResults));
|
||||
=> await schemaAppService.GetListsAsync(search, maxResults);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DescribeList, ReadOnly = true)]
|
||||
[Description("Tek bir listenin sutunlarini dondurur: alan adi, kullaniciya gorunen basligi, SQL tipi, lookup iliskisi ve sutunun hassas olup olmadigi.")]
|
||||
public async Task<CallToolResult> DescribeListAsync(
|
||||
public async Task<McpListDetailDto?> DescribeListAsync(
|
||||
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
|
||||
string listFormCode)
|
||||
=> McpToolResult.From(await schemaAppService.GetListAsync(listFormCode));
|
||||
=> await schemaAppService.GetListAsync(listFormCode);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SearchSchema, ReadOnly = true)]
|
||||
[Description("Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama yapar; hangi listenin hangi sutununun arandigini bulmak icin kullanilir.")]
|
||||
public async Task<CallToolResult> SearchSchemaAsync(
|
||||
public async Task<List<McpSchemaMatchDto>> SearchSchemaAsync(
|
||||
[Description("Aranacak metin. Turkce karakterler ve aksanlar onemsizdir.")]
|
||||
string term,
|
||||
[Description("Donecek en fazla eslesme sayisi. Varsayilan 50.")]
|
||||
int maxResults = 50)
|
||||
=> McpToolResult.From(await schemaAppService.SearchAsync(term, maxResults));
|
||||
=> await schemaAppService.SearchAsync(term, maxResults);
|
||||
}
|
||||
|
||||
/// <param name="Scope">Istegin baglami: <c>host</c> ya da <c>tenant</c>. Gelistirme araclari yalnizca host'ta aciktir.</param>
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Sozsoft.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Servis sonucunu arac sonucuna cevirir. Deger SDK'nin donus degerini yazdigi bicimde
|
||||
/// (<see cref="McpJsonUtilities.DefaultOptions"/>) JSON metnidir. <see cref="McpProblem"/> hata
|
||||
/// sonucudur (<c>IsError</c>): istisna atilmadan modele ulasir, model nedeni okuyup girdiyi duzeltir.
|
||||
/// </summary>
|
||||
internal static class McpToolResult
|
||||
{
|
||||
/// <summary>Degeri olmayan araclarin (not ekleme, silme) basari cevabi.</summary>
|
||||
private const string Done = "ok";
|
||||
|
||||
public static CallToolResult From<T>(McpOutcome<T> outcome)
|
||||
=> outcome.TryGetValue(out var value, out var problem)
|
||||
? Text(JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions))
|
||||
: Error(problem.Message);
|
||||
|
||||
public static CallToolResult From(McpProblem? problem)
|
||||
=> problem is null ? Text(Done) : Error(problem.Message);
|
||||
|
||||
/// <summary>Degeri metin disinda icerik (resim) tasiyan araclar icin.</summary>
|
||||
public static CallToolResult From<T>(McpOutcome<T> outcome, Func<T, IList<ContentBlock>> toContent)
|
||||
=> outcome.TryGetValue(out var value, out var problem)
|
||||
? new CallToolResult { Content = toContent(value) }
|
||||
: Error(problem.Message);
|
||||
|
||||
public static CallToolResult Error(string message)
|
||||
=> new() { IsError = true, Content = [new TextContentBlock { Text = message }] };
|
||||
|
||||
private static CallToolResult Text(string text)
|
||||
=> new() { Content = [new TextContentBlock { Text = text }] };
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ public partial class NotificationTypeToNotificationTypeDtoMapper : MapperBase<No
|
|||
public override partial NotificationTypeDto Map(NotificationType source);
|
||||
|
||||
public override partial void Map(NotificationType source, NotificationTypeDto destination);
|
||||
|
||||
public override void BeforeMap(NotificationType source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(NotificationType source, NotificationTypeDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -20,6 +28,14 @@ public partial class NotificationRuleToNotificationRuleDtoMapper : MapperBase<No
|
|||
public override partial NotificationRuleDto Map(NotificationRule source);
|
||||
|
||||
public override partial void Map(NotificationRule source, NotificationRuleDto destination);
|
||||
|
||||
public override void BeforeMap(NotificationRule source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(NotificationRule source, NotificationRuleDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -37,6 +53,14 @@ public partial class NotificationToNotificationDtoMapper : MapperBase<Notificati
|
|||
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))]
|
||||
[MapperIgnoreTarget(nameof(NotificationDto.TenantId))]
|
||||
public override partial void Map(Notification source, NotificationDto destination);
|
||||
|
||||
public override void BeforeMap(Notification source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(Notification source, NotificationDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||
|
|
@ -71,4 +95,12 @@ public partial class CreateUpdateNotificationRuleDtoToNotificationRuleMapper : M
|
|||
[MapperIgnoreTarget(nameof(NotificationRule.TenantId))]
|
||||
[MapperIgnoreTarget(nameof(NotificationRule.RecipientType))]
|
||||
public override partial void Map(CreateUpdateNotificationRuleDto source, NotificationRule destination);
|
||||
|
||||
public override void BeforeMap(CreateUpdateNotificationRuleDto source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(CreateUpdateNotificationRuleDto source, NotificationRule destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ public static class NotificationConsts
|
|||
|
||||
/// <summary>
|
||||
/// Elle yapilan gonderimlerin bildirim tipi. Kural motorundan gecmeyen her bildirim bu tiple
|
||||
/// yazilir; tip kaydi seed ile gelir (<c>Adm.Notifications.NotificationTypes.json</c>).
|
||||
/// yazilir; tip kaydi seed ile gelir (<c>App.Notifications.NotificationTypes.json</c>).
|
||||
/// </summary>
|
||||
public const string ManualTypeName = "Manuel";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Localization;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Notifications.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Bildirim metninin (tarih, sayi, dil anahtari) uretildigi kultur. Istek disinda (worker) is
|
||||
/// parcaciginin kulturu surec varsayilanidir; alicinin kendi dil tercihi platformda saklanmaz
|
||||
/// (dil istekte <c>Accept-Language</c> ile gelir), bu yuzden ulasilabilen en yakin deger
|
||||
/// kiracinin <c>Abp.Localization.DefaultLanguage</c> ayaridir (kiraci → global → varsayilan).
|
||||
/// </summary>
|
||||
public interface INotificationCulture
|
||||
{
|
||||
Task<IDisposable> UseAsync();
|
||||
}
|
||||
|
||||
public class NotificationCulture(ISettingProvider settingProvider) : INotificationCulture, ITransientDependency
|
||||
{
|
||||
public async Task<IDisposable> UseAsync()
|
||||
{
|
||||
var culture = await settingProvider.GetOrNullAsync(LocalizationSettingNames.DefaultLanguage);
|
||||
return culture.IsNullOrWhiteSpace() || !CultureHelper.IsValidCultureCode(culture!)
|
||||
? NullDisposable.Instance
|
||||
: CultureHelper.Use(culture!);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,6 @@ public class NotificationManager : INotificationManager
|
|||
private readonly INotificationRealtimeNotifier realtimeNotifier;
|
||||
private readonly ICurrentTenant currentTenant;
|
||||
private readonly IClock clock;
|
||||
private readonly INotificationCulture notificationCulture;
|
||||
|
||||
public NotificationManager(
|
||||
IReadOnlyRepository<NotificationRule> repository,
|
||||
|
|
@ -81,8 +80,7 @@ public class NotificationManager : INotificationManager
|
|||
IOrganizationUnitRepository repositoryOrganizationUnit,
|
||||
INotificationRealtimeNotifier realtimeNotifier,
|
||||
ICurrentTenant currentTenant,
|
||||
IClock clock,
|
||||
INotificationCulture notificationCulture
|
||||
IClock clock
|
||||
)
|
||||
{
|
||||
this.repository = repository;
|
||||
|
|
@ -94,7 +92,6 @@ public class NotificationManager : INotificationManager
|
|||
this.realtimeNotifier = realtimeNotifier;
|
||||
this.currentTenant = currentTenant;
|
||||
this.clock = clock;
|
||||
this.notificationCulture = notificationCulture;
|
||||
}
|
||||
|
||||
public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage)
|
||||
|
|
@ -211,36 +208,30 @@ public class NotificationManager : INotificationManager
|
|||
.ToListAsync();
|
||||
|
||||
var notifications = new List<Notification>();
|
||||
|
||||
// {Date}/{Time} yer tutuculari gonderenin istek kulturunde degil kiracinin dilinde yazilir;
|
||||
// alicinin kendi dil tercihi platformda saklanmadigi icin alici basina ayrim yapilamaz.
|
||||
using (await notificationCulture.UseAsync())
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
foreach (var user in users)
|
||||
{
|
||||
foreach (var user in users)
|
||||
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
|
||||
if (identifier.IsNullOrWhiteSpace())
|
||||
{
|
||||
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
|
||||
if (identifier.IsNullOrWhiteSpace())
|
||||
{
|
||||
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
notifications.Add(new Notification
|
||||
{
|
||||
NotificationTypeId = request.NotificationTypeId,
|
||||
NotificationTemplateId = request.NotificationTemplateId,
|
||||
NotificationRuleId = anchorRule.Id,
|
||||
NotificationChannel = channel,
|
||||
Identifier = identifier!,
|
||||
UserId = user.Id,
|
||||
Message = ResolveTokens(request.Message, user),
|
||||
Subject = request.Subject,
|
||||
IsSent = IsDeliveredOnCreate(channel),
|
||||
IsRead = false
|
||||
});
|
||||
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
notifications.Add(new Notification
|
||||
{
|
||||
NotificationTypeId = request.NotificationTypeId,
|
||||
NotificationTemplateId = request.NotificationTemplateId,
|
||||
NotificationRuleId = anchorRule.Id,
|
||||
NotificationChannel = channel,
|
||||
Identifier = identifier!,
|
||||
UserId = user.Id,
|
||||
Message = ResolveTokens(request.Message, user),
|
||||
Subject = request.Subject,
|
||||
IsSent = IsDeliveredOnCreate(channel),
|
||||
IsRead = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Sozsoft.Sender.Localization;
|
||||
|
||||
[LocalizationResourceName("SozsoftSender")]
|
||||
public class SozsoftMailingResource
|
||||
public class ErpSenderResource
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
}
|
||||
|
||||
await BackgroundJobManager.EnqueueAsync(
|
||||
new SozsoftBackgroundEmailSendingJobArgs
|
||||
new ErpBackgroundEmailSendingJobArgs
|
||||
{
|
||||
TenantId = CurrentTenant.Id,
|
||||
To = to,
|
||||
|
|
@ -76,7 +76,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
);
|
||||
}
|
||||
|
||||
public async Task<SendResult> SendEmailAsync(
|
||||
public async Task<EmailSendResult> SendEmailAsync(
|
||||
string[] to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
@ -134,7 +134,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
|
||||
using var client = await amazonSesClientFactory.GetAmazonSesClient();
|
||||
var response = await client.SendEmailAsync(request);
|
||||
var result = new SendResult();
|
||||
var result = new EmailSendResult();
|
||||
if (response == null)
|
||||
{
|
||||
result.Success = false;
|
||||
|
|
@ -157,7 +157,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
return result;
|
||||
}
|
||||
|
||||
public async Task<SendResult> SendEmailAsync(
|
||||
public async Task<EmailSendResult> SendEmailAsync(
|
||||
string to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
|
|||
9
api/modules/Sozsoft.Sender/Mail/EmailSendResult.cs
Normal file
9
api/modules/Sozsoft.Sender/Mail/EmailSendResult.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
public class EmailSendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string MessageId { get; set; } //Eger basarili ise gateway messageId
|
||||
public string ErrorMessage { get; set; } //Basarisiz ise hata mesaji
|
||||
}
|
||||
|
||||
|
|
@ -7,7 +7,8 @@ namespace Sozsoft.Sender.Mail;
|
|||
/// </summary>
|
||||
public interface ISozsoftEmailSender : IEmailSender
|
||||
{
|
||||
Task<SendResult> SendEmailAsync(
|
||||
//ErpEmailTemplate template
|
||||
Task<EmailSendResult> SendEmailAsync(
|
||||
string to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
@ -15,7 +16,7 @@ public interface ISozsoftEmailSender : IEmailSender
|
|||
string? subject = null,
|
||||
Dictionary<string, string>? attachments = null,
|
||||
bool IsBodyHtml = false);
|
||||
Task<SendResult> SendEmailAsync(
|
||||
Task<EmailSendResult> SendEmailAsync(
|
||||
string[] to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ using Volo.Abp.MultiTenancy;
|
|||
|
||||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
public class SozsoftBackgroundEmailSendingJob :
|
||||
AsyncBackgroundJob<SozsoftBackgroundEmailSendingJobArgs>, ITransientDependency
|
||||
public class ErpBackgroundEmailSendingJob :
|
||||
AsyncBackgroundJob<ErpBackgroundEmailSendingJobArgs>, ITransientDependency
|
||||
{
|
||||
protected ISozsoftEmailSender EmailSender { get; }
|
||||
protected ICurrentTenant CurrentTenant { get; }
|
||||
|
||||
public SozsoftBackgroundEmailSendingJob(
|
||||
public ErpBackgroundEmailSendingJob(
|
||||
ISozsoftEmailSender emailSender,
|
||||
ICurrentTenant currentTenant)
|
||||
{
|
||||
|
|
@ -18,10 +18,11 @@ public class SozsoftBackgroundEmailSendingJob :
|
|||
CurrentTenant = currentTenant;
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(SozsoftBackgroundEmailSendingJobArgs args)
|
||||
public override async Task ExecuteAsync(ErpBackgroundEmailSendingJobArgs args)
|
||||
{
|
||||
using (CurrentTenant.Change(args.TenantId))
|
||||
{
|
||||
//await EmailSender.SendEmailAsync(args.Template, args.To, args.Params, args.Subject);
|
||||
await EmailSender.SendEmailAsync(
|
||||
args.To,
|
||||
args.Sender,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
[Serializable]
|
||||
public class SozsoftBackgroundEmailSendingJobArgs : IMultiTenant
|
||||
public class ErpBackgroundEmailSendingJobArgs : IMultiTenant
|
||||
{
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ namespace Sozsoft.Sender.Rocket;
|
|||
|
||||
public interface ISozsoftRocketSender : ITransientDependency
|
||||
{
|
||||
Task<SendResult> SendAsync(string Username, string Message);
|
||||
Task<RocketSendResult> SendAsync(string Username, string Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,46 @@
|
|||
using Volo.Abp.Settings;
|
||||
using System.Net.Http.Json;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public interface IRocketHttpClient
|
||||
{
|
||||
Task<SendResult?> SendMessageAsync(string Username, string Message);
|
||||
Task<RocketSendResultDto> SendMessageAsync(string Username, string Message);
|
||||
}
|
||||
|
||||
public sealed class RocketHttpClient : SettingHttpClientBase, IRocketHttpClient
|
||||
public sealed class RocketHttpClient : IRocketHttpClient, IDisposable
|
||||
{
|
||||
public RocketHttpClient(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
: base(httpClient, settingProvider)
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ISettingProvider settingProvider;
|
||||
|
||||
public RocketHttpClient(
|
||||
HttpClient httpClient,
|
||||
ISettingProvider settingProvider)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.settingProvider = settingProvider;
|
||||
}
|
||||
|
||||
public async Task<SendResult?> SendMessageAsync(string Username, string Message)
|
||||
public async Task<RocketSendResultDto> SendMessageAsync(string Username, string Message)
|
||||
{
|
||||
var url = await GetSettingAsync(RocketSettingNames.Url);
|
||||
var userId = await GetSettingAsync(RocketSettingNames.UserId);
|
||||
var token = await GetSettingAsync(RocketSettingNames.Token);
|
||||
var url = await settingProvider.GetOrNullAsync(RocketSettingNames.Url);
|
||||
var userId = await settingProvider.GetOrNullAsync(RocketSettingNames.UserId);
|
||||
var token = await settingProvider.GetOrNullAsync(RocketSettingNames.Token);
|
||||
|
||||
// Rocket.Chat cevabı yalnızca { success } taşır; SendResult aynı sözleşmedir, ayrı DTO gerekmez.
|
||||
return await PostJsonAsync<SendResult>(
|
||||
$"{url}/chat.postMessage",
|
||||
new RocketChatPostMessageInput
|
||||
{
|
||||
Channel = $"@{Username}",
|
||||
Text = Message
|
||||
},
|
||||
request =>
|
||||
{
|
||||
request.Headers.Add("X-Auth-Token", token);
|
||||
request.Headers.Add("X-User-Id", userId);
|
||||
},
|
||||
ensureSuccess: true);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{url}/chat.postMessage");
|
||||
request.Headers.Add("X-Auth-Token", token);
|
||||
request.Headers.Add("X-User-Id", userId);
|
||||
request.Content = JsonContent.Create(new RocketChatPostMessageInput
|
||||
{
|
||||
Channel = $"@{Username}",
|
||||
Text = Message
|
||||
});
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode(); // Hata durumunda istisna atar.
|
||||
return await response.Content.ReadFromJsonAsync<RocketSendResultDto>();
|
||||
}
|
||||
|
||||
public void Dispose() => httpClient?.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
7
api/modules/Sozsoft.Sender/Rocket/RocketSendResult.cs
Normal file
7
api/modules/Sozsoft.Sender/Rocket/RocketSendResult.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public class RocketSendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
7
api/modules/Sozsoft.Sender/Rocket/RocketSendResultDto.cs
Normal file
7
api/modules/Sozsoft.Sender/Rocket/RocketSendResultDto.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public class RocketSendResultDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -15,12 +15,11 @@ public class RocketSender : ISozsoftRocketSender
|
|||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SendResult> SendAsync(string Username, string Message)
|
||||
public async Task<RocketSendResult> SendAsync(string Username, string Message)
|
||||
{
|
||||
logger.LogInformation("Sending Rocket message. {Username}, {Message}", Username, Message);
|
||||
var result = await httpClient.SendMessageAsync(Username, Message);
|
||||
var success = result?.Success == true;
|
||||
if (success)
|
||||
if (result.Success)
|
||||
{
|
||||
logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message);
|
||||
}
|
||||
|
|
@ -29,6 +28,10 @@ public class RocketSender : ISozsoftRocketSender
|
|||
logger.LogWarning("Failed to send Rocket message. {Username}, {Message}", Username, Message);
|
||||
}
|
||||
|
||||
return new SendResult { Success = success };
|
||||
return new RocketSendResult
|
||||
{
|
||||
Success = result.Success,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
public static class RocketSettingNames
|
||||
{
|
||||
public const string Default = "Adm.Sender.Rocket.";
|
||||
public const string Default = "App.Sender.Rocket.";
|
||||
public const string Url = Default + "Url";
|
||||
public const string UserId = Default + "UserId";
|
||||
public const string Token = Default + "Token";
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
namespace Sozsoft.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Her kanalın (e-posta, SMS, Rocket, WhatsApp, Telegram) tek gönderim sonucu. Kanal yalnızca
|
||||
/// başarı bilgisi veriyorsa <see cref="MessageId"/> ve <see cref="ErrorMessage"/> boş kalır.
|
||||
/// </summary>
|
||||
public class SendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Başarılı gönderimde sağlayıcının verdiği ileti kimliği.</summary>
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>Başarısız gönderimde sağlayıcının açıklaması.</summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
using System.Net.Http.Json;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Sağlayıcı istemcilerinin ortak iskeleti: <see cref="IHttpClientFactory"/> ile verilen
|
||||
/// <see cref="HttpClient"/>, ayarlardan okunan uç nokta ve JSON gövde ile POST. Sağlayıcıya özgü
|
||||
/// olan yalnızca istek gövdesi, başlıklar ve cevap tipidir.
|
||||
/// </summary>
|
||||
public abstract class SettingHttpClientBase : IDisposable
|
||||
{
|
||||
protected HttpClient HttpClient { get; }
|
||||
protected ISettingProvider SettingProvider { get; }
|
||||
|
||||
protected SettingHttpClientBase(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
{
|
||||
HttpClient = httpClient;
|
||||
SettingProvider = settingProvider;
|
||||
}
|
||||
|
||||
protected Task<string?> GetSettingAsync(string name) => SettingProvider.GetOrNullAsync(name);
|
||||
|
||||
/// <summary>
|
||||
/// Gövdeyi JSON olarak gönderir ve cevabı <typeparamref name="TResponse"/> olarak okur.
|
||||
/// <paramref name="ensureSuccess"/> kapalıyken 4xx gövdesi de okunur: bazı API'ler hatayı
|
||||
/// durum koduyla değil gövdedeki açıklamayla verir.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostJsonAsync<TResponse>(
|
||||
string url,
|
||||
object body,
|
||||
Action<HttpRequestMessage>? configureRequest = null,
|
||||
bool ensureSuccess = false)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
|
||||
configureRequest?.Invoke(request);
|
||||
|
||||
using var response = await HttpClient.SendAsync(request);
|
||||
if (ensureSuccess)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,6 @@ namespace Sozsoft.Sender.Sms;
|
|||
|
||||
public interface ISozsoftSmsSender : ISmsSender, ITransientDependency
|
||||
{
|
||||
Task<SendResult> SendAsync(string PhoneNumber, string Message);
|
||||
Task<SmsSendResult> SendAsync(string PhoneNumber, string Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,51 +7,61 @@ public interface IPostaGuverciniHttpClient
|
|||
Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message);
|
||||
}
|
||||
|
||||
public sealed class PostaGuverciniHttpClient : SettingHttpClientBase, IPostaGuverciniHttpClient
|
||||
public sealed class PostaGuverciniHttpClient : IPostaGuverciniHttpClient, IDisposable
|
||||
{
|
||||
public PostaGuverciniHttpClient(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
: base(httpClient, settingProvider)
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ISettingProvider settingProvider;
|
||||
|
||||
public PostaGuverciniHttpClient(
|
||||
HttpClient httpClient,
|
||||
ISettingProvider settingProvider)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.settingProvider = settingProvider;
|
||||
}
|
||||
|
||||
public async Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message)
|
||||
{
|
||||
var url = await GetSettingAsync(PostaGuverciniSettingNames.Url);
|
||||
var user = await GetSettingAsync(PostaGuverciniSettingNames.Username);
|
||||
var password = await GetSettingAsync(PostaGuverciniSettingNames.Password);
|
||||
|
||||
// Sağlayıcı yalnızca sorgu dizisi kabul eder; metindeki '&' ve '=' kaçırılmazsa parametre sınırı bozulur.
|
||||
var response = await HttpClient.GetStringAsync(
|
||||
$"{url}/sendsms.asp?user={Uri.EscapeDataString(user ?? string.Empty)}&password={Uri.EscapeDataString(password ?? string.Empty)}" +
|
||||
$"&gsm={Uri.EscapeDataString(PhoneNumber)}&text={Uri.EscapeDataString(Message)}");
|
||||
|
||||
// Başarı: errno=0&errtext=&message_id=EZ_2643D2B5-...&charge=-1
|
||||
// Hata: errno=10&errtext=HATA: Giriş bilgileri hatalı!
|
||||
var url = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Url);
|
||||
var u = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Username);
|
||||
var p = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Password);
|
||||
//httpClient.BaseAddress = new Uri(url);
|
||||
var response = await httpClient.GetStringAsync($"{url}/sendsms.asp?user={u}&password={p}&gsm={PhoneNumber}&text={Message}");
|
||||
// Gelen result başarı: errno=0&errtext=&message_id=EZ_2643D2B5-QCFD-40B7-E95F-4204CFCD0409&charge=-1
|
||||
// Gelen result hata: errno=10&errtext=HATA: Giriş bilgileri hatalı!
|
||||
var result = new PostaGuverciniSmsSendResultDto();
|
||||
var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
|
||||
foreach (var item in response.Split('&', options))
|
||||
try
|
||||
{
|
||||
var token = item.Split('=', 2, options);
|
||||
var value = token.Length == 2 ? token[1] : null;
|
||||
switch (token[0])
|
||||
var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
|
||||
foreach (var item in response.Split('&', options))
|
||||
{
|
||||
case "errno":
|
||||
result.ErrNo = value;
|
||||
break;
|
||||
case "errtext":
|
||||
result.ErrText = value;
|
||||
break;
|
||||
case "message_id":
|
||||
result.MessageId = value;
|
||||
break;
|
||||
case "charge":
|
||||
result.Charge = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
var token = item.Split('=', options);
|
||||
switch (token[0])
|
||||
{
|
||||
case "errno":
|
||||
result.ErrNo = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "errtext":
|
||||
result.ErrText = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "message_id":
|
||||
result.MessageId = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "charge":
|
||||
result.Charge = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose() => httpClient?.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
public static class PostaGuverciniSettingNames
|
||||
{
|
||||
public const string Default = "Adm.Sender.Sms.PostaGuvercini.";
|
||||
public const string Default = "App.Sender.Sms.PostaGuvercini.";
|
||||
public const string Url = Default + "Url";
|
||||
public const string Username = Default + "Username";
|
||||
public const string Password = Default + "Password";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public class PostaGuverciniSmsSender : ISozsoftSmsSender
|
|||
|
||||
public Task SendAsync(SmsMessage smsMessage) => SendAsync(smsMessage.PhoneNumber, smsMessage.Text);
|
||||
|
||||
public async Task<SendResult> SendAsync(string PhoneNumber, string Message)
|
||||
public async Task<SmsSendResult> SendAsync(string PhoneNumber, string Message)
|
||||
{
|
||||
PhoneNumber = Check.NotNullOrWhiteSpace(PhoneNumber, nameof(PhoneNumber), 11, 10);
|
||||
Message = Check.NotNullOrWhiteSpace(Message, nameof(Message));
|
||||
|
|
@ -36,7 +36,7 @@ public class PostaGuverciniSmsSender : ISozsoftSmsSender
|
|||
logger.LogWarning("Failed to send SMS message. {PhoneNumber}, {Message}, {Error}", PhoneNumber, Message, $"{result.ErrNo}:{result.ErrText}");
|
||||
}
|
||||
|
||||
return new SendResult
|
||||
return new SmsSendResult
|
||||
{
|
||||
Success = result.IsSuccess,
|
||||
ErrorMessage = $"{result.ErrNo}:{result.ErrText}",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue